Add channel test providers for Email, Slack, Teams, and Webhook

- Implemented EmailChannelTestProvider to generate email preview payloads.
- Implemented SlackChannelTestProvider to create Slack message previews.
- Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews.
- Implemented WebhookChannelTestProvider to create webhook payloads.
- Added INotifyChannelTestProvider interface for channel-specific preview generation.
- Created ChannelTestPreviewContracts for request and response models.
- Developed NotifyChannelTestService to handle test send requests and generate previews.
- Added rate limit policies for test sends and delivery history.
- Implemented unit tests for service registration and binding.
- Updated project files to include necessary dependencies and configurations.
This commit is contained in:
master
2025-10-19 23:29:34 +03:00
parent a811f7ac47
commit a07f46231b
239 changed files with 17245 additions and 3155 deletions

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
@@ -43,6 +45,74 @@ public class StandardClientProvisioningStoreTests
Assert.Contains("scopeA", descriptor.AllowedScopes);
}
[Fact]
public async Task CreateOrUpdateAsync_StoresAudiences()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "signer",
confidential: false,
displayName: "Signer",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "attestor", "signer" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
var document = Assert.Contains("signer", store.Documents);
Assert.Equal("attestor signer", document.Value.Properties[AuthorityClientMetadataKeys.Audiences]);
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.Audiences.OrderBy(value => value, StringComparer.Ordinal));
}
[Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
thumbprint: "aa:bb:cc:dd",
serialNumber: "01ff",
subject: "CN=mtls-client",
issuer: "CN=test-ca",
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
notAfter: DateTimeOffset.UtcNow.AddHours(1),
label: "primary");
var registration = new AuthorityClientRegistration(
clientId: "mtls-client",
confidential: true,
displayName: "MTLS Client",
clientSecret: "secret",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "signer" },
certificateBindings: new[] { bindingRegistration });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
var document = Assert.Contains("mtls-client", store.Documents).Value;
var binding = Assert.Single(document.CertificateBindings);
Assert.Equal("AABBCCDD", binding.Thumbprint);
Assert.Equal("01ff", binding.SerialNumber);
Assert.Equal("CN=mtls-client", binding.Subject);
Assert.Equal("CN=test-ca", binding.Issuer);
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
Assert.Equal("primary", binding.Label);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);

View File

@@ -50,11 +50,21 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = string.Join(" ", registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = string.Join(" ", registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
if (registration.CertificateBindings is not null)
{
var now = clock.GetUtcNow();
document.CertificateBindings = registration.CertificateBindings
.Select(binding => MapCertificateBinding(binding, now))
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
.ToList();
}
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
@@ -142,12 +152,15 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
.Cast<Uri>()
.ToArray();
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
audiences,
redirectUris,
postLogoutUris,
document.Properties);
@@ -163,6 +176,47 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
{
return string.Empty;
}
return string.Join(
" ",
values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal));
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()
: registration.SubjectAlternativeNames
.Select(name => name.Trim())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AuthorityClientCertificateBinding
{
Thumbprint = registration.Thumbprint,
SerialNumber = registration.SerialNumber,
Subject = registration.Subject,
Issuer = registration.Issuer,
SubjectAlternativeNames = subjectAlternativeNames,
NotBefore = registration.NotBefore,
NotAfter = registration.NotAfter,
Label = registration.Label,
CreatedAt = now,
UpdatedAt = now
};
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))

View File

@@ -5,10 +5,10 @@
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1PLG5 | 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 | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | DOING (2025-10-14) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC2.PLG | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⏳ Awaiting AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 completion to unlock Wave0B verification paths. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | DOING (2025-10-14) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⏳ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 so limiter telemetry contract matches final authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC4.PLG | DONE (2025-10-12) | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⏳ Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | 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. |
@@ -16,3 +16,5 @@
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.
> Remark (2025-10-13, PLG6.DOC/PLG6.DIAGRAM): Security Guild delivered `docs/security/rate-limits.md`; Docs team can lift Section 3 (tuning table + alerts) into the developer guide diagrams when rendering assets.
> Check-in (2025-10-19): Wave0A dependencies (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open, so SEC2/SEC3/SEC5 remain in progress without new scope until upstream limiter updates land.

View File

@@ -7,6 +7,7 @@ public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
public const string AllowedScopes = "allowedScopes";
public const string Audiences = "audiences";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";

View File

@@ -632,15 +632,13 @@ public sealed class AuthorityClaimsEnrichmentContext
/// </summary>
public sealed record AuthorityClientDescriptor
{
/// <summary>
/// Initialises a new client descriptor.
/// </summary>
public AuthorityClientDescriptor(
string clientId,
string? displayName,
bool confidential,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<string>? allowedAudiences = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
@@ -648,8 +646,9 @@ public sealed record AuthorityClientDescriptor
ClientId = ValidateRequired(clientId, nameof(clientId));
DisplayName = displayName;
Confidential = confidential;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
AllowedGrantTypes = Normalize(allowedGrantTypes);
AllowedScopes = Normalize(allowedScopes);
AllowedAudiences = Normalize(allowedAudiences);
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
@@ -657,60 +656,87 @@ public sealed record AuthorityClientDescriptor
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Permitted OAuth grant types.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Permitted scopes.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Registered redirect URIs.
/// </summary>
public IReadOnlyCollection<string> AllowedAudiences { get; }
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Registered post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional plugin-defined metadata.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
=> values is null || values.Count == 0
? Array.Empty<string>()
: values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}
/// <summary>
/// Client registration payload used when provisioning clients through plugins.
/// </summary>
public sealed record AuthorityClientCertificateBindingRegistration
{
public AuthorityClientCertificateBindingRegistration(
string thumbprint,
string? serialNumber = null,
string? subject = null,
string? issuer = null,
IReadOnlyCollection<string>? subjectAlternativeNames = null,
DateTimeOffset? notBefore = null,
DateTimeOffset? notAfter = null,
string? label = null)
{
Thumbprint = NormalizeThumbprint(thumbprint);
SerialNumber = Normalize(serialNumber);
Subject = Normalize(subject);
Issuer = Normalize(issuer);
SubjectAlternativeNames = subjectAlternativeNames is null || subjectAlternativeNames.Count == 0
? Array.Empty<string>()
: subjectAlternativeNames
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
NotBefore = notBefore;
NotAfter = notAfter;
Label = Normalize(label);
}
public string Thumbprint { get; }
public string? SerialNumber { get; }
public string? Subject { get; }
public string? Issuer { get; }
public IReadOnlyCollection<string> SubjectAlternativeNames { get; }
public DateTimeOffset? NotBefore { get; }
public DateTimeOffset? NotAfter { get; }
public string? Label { get; }
private static string NormalizeThumbprint(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Thumbprint is required.", nameof(value));
}
return value
.Replace(":", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal)
.ToUpperInvariant();
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record AuthorityClientRegistration
{
/// <summary>
/// Initialises a new registration.
/// </summary>
public AuthorityClientRegistration(
string clientId,
bool confidential,
@@ -718,9 +744,11 @@ public sealed record AuthorityClientRegistration
string? clientSecret,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<string>? allowedAudiences = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
IReadOnlyDictionary<string, string?>? properties = null,
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
Confidential = confidential;
@@ -728,65 +756,42 @@ public sealed record AuthorityClientRegistration
ClientSecret = confidential
? ValidateRequired(clientSecret ?? string.Empty, nameof(clientSecret))
: clientSecret;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
AllowedGrantTypes = Normalize(allowedGrantTypes);
AllowedScopes = Normalize(allowedScopes);
AllowedAudiences = Normalize(allowedAudiences);
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
CertificateBindings = certificateBindings is null
? Array.Empty<AuthorityClientCertificateBindingRegistration>()
: certificateBindings.ToArray();
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret handling).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Optional raw client secret (hashed by the plugin for storage).
/// </summary>
public string? ClientSecret { get; init; }
/// <summary>
/// Grant types to enable.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Scopes assigned to the client.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Redirect URIs permitted for the client.
/// </summary>
public IReadOnlyCollection<string> AllowedAudiences { get; }
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional metadata for the plugin.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
public IReadOnlyCollection<AuthorityClientCertificateBindingRegistration> CertificateBindings { get; }
/// <summary>
/// Creates a copy of the registration with the provided client secret.
/// </summary>
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, RedirectUris, PostLogoutRedirectUris, Properties);
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Properties, CertificateBindings);
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
=> values is null || values.Count == 0
? Array.Empty<string>()
: values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)

View File

@@ -62,6 +62,18 @@ public sealed class AuthorityTokenDocument
[BsonIgnoreIfNull]
public string? RevokedReasonDescription { get; set; }
[BsonElement("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("senderKeyThumbprint")]
[BsonIgnoreIfNull]
public string? SenderKeyThumbprint { get; set; }
[BsonElement("senderNonce")]
[BsonIgnoreIfNull]
public string? SenderNonce { get; set; }
[BsonElement("devices")]
[BsonIgnoreIfNull]

View File

@@ -27,7 +27,13 @@ internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollection
Builders<AuthorityTokenDocument>.IndexKeys
.Ascending(t => t.Status)
.Ascending(t => t.RevokedAt),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" })
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderConstraint),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_constraint", Sparse = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SenderKeyThumbprint),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_thumbprint", Sparse = true })
};
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);

View File

@@ -1,9 +1,21 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Configuration;
using StellaOps.Authority.Security;
using StellaOps.Auth.Security.Dpop;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
@@ -44,6 +56,8 @@ public class ClientCredentialsHandlersTests
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write");
@@ -72,6 +86,8 @@ public class ClientCredentialsHandlersTests
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -104,6 +120,8 @@ public class ClientCredentialsHandlersTests
sink,
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
@@ -117,6 +135,315 @@ public class ClientCredentialsHandlersTests
string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task ValidateDpopProof_AllowsSenderConstrainedClient()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Dpop.Enabled = true;
options.Security.SenderConstraints.Dpop.Nonce.Enabled = false;
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Guid.NewGuid().ToString("N")
};
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
var expectedThumbprint = ConvertThumbprintToString(jwk.ComputeJwkThumbprint());
var clientStore = new TestClientStore(clientDocument);
var auditSink = new TestAuthEventSink();
var rateMetadata = new TestRateLimiterMetadataAccessor();
var dpopValidator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var dpopHandler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
rateMetadata,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
var now = TimeProvider.System.GetUtcNow();
var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds());
httpContext.Request.Headers["DPoP"] = proof;
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await dpopHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var validateHandler = new ValidateClientCredentialsHandler(
clientStore,
registry,
TestActivitySource,
auditSink,
rateMetadata,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
await validateHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullMongoSessionAccessor();
var handleHandler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handleHandler.HandleAsync(handleContext);
Assert.True(handleContext.IsRequestHandled);
var persistHandler = new PersistTokensHandler(
tokenStore,
sessionAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<PersistTokensHandler>.Instance);
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
Principal = handleContext.Principal,
AccessTokenPrincipal = handleContext.Principal
};
await persistHandler.HandleAsync(signInContext);
var confirmationClaim = handleContext.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmationClaim));
using (var confirmationJson = JsonDocument.Parse(confirmationClaim!))
{
Assert.Equal(expectedThumbprint, confirmationJson.RootElement.GetProperty("jkt").GetString());
}
Assert.NotNull(tokenStore.Inserted);
Assert.Equal(AuthoritySenderConstraintKinds.Dpop, tokenStore.Inserted!.SenderConstraint);
Assert.Equal(expectedThumbprint, tokenStore.Inserted!.SenderKeyThumbprint);
}
[Fact]
public async Task ValidateDpopProof_IssuesNonceChallenge_WhenNonceMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Dpop.Enabled = true;
options.Security.SenderConstraints.Dpop.Nonce.Enabled = true;
options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Clear();
options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences.Add("signer");
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
Assert.Contains("signer", options.Security.SenderConstraints.Dpop.Nonce.RequiredAudiences);
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
allowedAudiences: "signer");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Dpop;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Dpop;
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Guid.NewGuid().ToString("N")
};
var clientStore = new TestClientStore(clientDocument);
var auditSink = new TestAuthEventSink();
var rateMetadata = new TestRateLimiterMetadataAccessor();
var dpopValidator = new DpopProofValidator(
Options.Create(new DpopValidationOptions()),
new InMemoryDpopReplayCache(TimeProvider.System),
TimeProvider.System,
NullLogger<DpopProofValidator>.Instance);
var nonceStore = new InMemoryDpopNonceStore(TimeProvider.System, NullLogger<InMemoryDpopNonceStore>.Instance);
var dpopHandler = new ValidateDpopProofHandler(
options,
clientStore,
dpopValidator,
nonceStore,
rateMetadata,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateDpopProofHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Options = new OpenIddictServerOptions();
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "POST";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("authority.test");
httpContext.Request.Path = "/token";
var now = TimeProvider.System.GetUtcNow();
var proof = TestHelpers.CreateDpopProof(securityKey, httpContext.Request.Method, httpContext.Request.GetDisplayUrl(), now.ToUnixTimeSeconds());
httpContext.Request.Headers["DPoP"] = proof;
transaction.Properties[typeof(HttpContext).FullName!] = httpContext;
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await dpopHandler.HandleAsync(validateContext);
Assert.True(validateContext.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, validateContext.Error);
var authenticateHeader = Assert.Single(httpContext.Response.Headers.Select(header => header)
.Where(header => string.Equals(header.Key, "WWW-Authenticate", StringComparison.OrdinalIgnoreCase))).Value;
Assert.Contains("use_dpop_nonce", authenticateHeader.ToString());
Assert.True(httpContext.Response.Headers.TryGetValue("DPoP-Nonce", out var nonceValues));
Assert.False(StringValues.IsNullOrEmpty(nonceValues));
Assert.Contains(auditSink.Events, record => record.EventType == "authority.dpop.proof.challenge");
}
[Fact]
public async Task ValidateClientCredentials_AllowsMtlsClient_WithValidCertificate()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls;
using var rsa = RSA.Create(2048);
var certificateRequest = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
var hexThumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256));
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = hexThumbprint
});
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var auditSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate;
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
auditSink,
metadataAccessor,
TimeProvider.System,
validator,
httpContextAccessor,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]);
var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256));
Assert.Equal(expectedBase64, context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty]);
}
[Fact]
public async Task ValidateClientCredentials_RejectsMtlsClient_WhenCertificateMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.Properties[AuthorityClientMetadataKeys.SenderConstraint] = AuthoritySenderConstraintKinds.Mtls;
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
validator,
httpContextAccessor,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
}
[Fact]
public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims()
{
@@ -124,7 +451,8 @@ public class ClientCredentialsHandlersTests
secret: null,
clientType: "public",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:trigger");
allowedScopes: "jobs:trigger",
allowedAudiences: "signer");
var descriptor = CreateDescriptor(clientDocument);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
@@ -139,6 +467,8 @@ public class ClientCredentialsHandlersTests
authSink,
metadataAccessor,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger");
@@ -163,6 +493,7 @@ public class ClientCredentialsHandlersTests
Assert.True(context.IsRequestHandled);
Assert.NotNull(context.Principal);
Assert.Contains("signer", context.Principal!.GetAudiences());
Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success);
@@ -285,6 +616,62 @@ public class TokenValidationHandlersTests
Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true");
}
[Fact]
public async Task ValidateAccessTokenHandler_AddsConfirmationClaim_ForMtlsToken()
{
var tokenDocument = new AuthorityTokenDocument
{
TokenId = "token-mtls",
Status = "valid",
ClientId = "mtls-client",
SenderConstraint = AuthoritySenderConstraintKinds.Mtls,
SenderKeyThumbprint = "thumb-print"
};
var tokenStore = new TestTokenStore
{
Inserted = tokenDocument
};
var clientDocument = CreateClient();
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
registry,
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Introspection,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, tokenDocument.TokenId, clientDocument.Plugin);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = tokenDocument.TokenId
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
var confirmation = context.Principal?.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
Assert.False(string.IsNullOrWhiteSpace(confirmation));
using var json = JsonDocument.Parse(confirmation!);
Assert.Equal(tokenDocument.SenderKeyThumbprint, json.RootElement.GetProperty("x5t#S256").GetString());
}
[Fact]
public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay()
{
@@ -358,6 +745,89 @@ public class TokenValidationHandlersTests
}
}
public class AuthorityClientCertificateValidatorTests
{
[Fact]
public async Task ValidateAsync_Rejects_WhenSanTypeNotAllowed()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear();
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Add("uri");
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("client.mtls.test");
request.CertificateExtensions.Add(sanBuilder.Build());
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(5));
var clientDocument = CreateClient();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256))
});
var httpContext = new DefaultHttpContext();
httpContext.Connection.ClientCertificate = certificate;
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal("certificate_san_type", result.Error);
}
[Fact]
public async Task ValidateAsync_AllowsBindingWithinRotationGrace()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Security.SenderConstraints.Mtls.RotationGrace = TimeSpan.FromMinutes(5);
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=mtls-client", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("client.mtls.test");
request.CertificateExtensions.Add(sanBuilder.Build());
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(10));
var thumbprint = Convert.ToHexString(certificate.GetCertHash(HashAlgorithmName.SHA256));
var clientDocument = CreateClient();
clientDocument.SenderConstraint = AuthoritySenderConstraintKinds.Mtls;
clientDocument.CertificateBindings.Add(new AuthorityClientCertificateBinding
{
Thumbprint = thumbprint,
NotBefore = TimeProvider.System.GetUtcNow().AddMinutes(2)
});
var httpContext = new DefaultHttpContext();
httpContext.Connection.ClientCertificate = certificate;
var validator = new AuthorityClientCertificateValidator(options, TimeProvider.System, NullLogger<AuthorityClientCertificateValidator>.Instance);
var result = await validator.ValidateAsync(httpContext, clientDocument, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal(thumbprint, result.HexThumbprint);
}
}
internal sealed class TestClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
@@ -526,6 +996,19 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
}
internal sealed class NoopCertificateValidator : IAuthorityClientCertificateValidator
{
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
{
var binding = new AuthorityClientCertificateBinding
{
Thumbprint = "stub"
};
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success("stub", "stub", binding));
}
}
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
@@ -540,9 +1023,10 @@ internal static class TestHelpers
string? secret = "s3cr3t!",
string clientType = "confidential",
string allowedGrantTypes = "client_credentials",
string allowedScopes = "jobs:read")
string allowedScopes = "jobs:read",
string allowedAudiences = "")
{
return new AuthorityClientDocument
var document = new AuthorityClientDocument
{
ClientId = "concelier",
ClientType = clientType,
@@ -554,12 +1038,20 @@ internal static class TestHelpers
[AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes
}
};
if (!string.IsNullOrWhiteSpace(allowedAudiences))
{
document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences;
}
return document;
}
public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
var allowedScopes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedScopes, out var scopes) ? scopes?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
var allowedAudiences = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Audiences, out var audiences) ? audiences?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>();
return new AuthorityClientDescriptor(
document.ClientId,
@@ -567,6 +1059,7 @@ internal static class TestHelpers
confidential: string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
allowedAudiences,
redirectUris: Array.Empty<Uri>(),
postLogoutRedirectUris: Array.Empty<Uri>(),
properties: document.Properties);
@@ -638,6 +1131,57 @@ internal static class TestHelpers
};
}
public static string ConvertThumbprintToString(object thumbprint)
=> thumbprint switch
{
string value => value,
byte[] bytes => Base64UrlEncoder.Encode(bytes),
_ => throw new InvalidOperationException("Unsupported thumbprint representation.")
};
public static string CreateDpopProof(ECDsaSecurityKey key, string method, string url, long issuedAt, string? nonce = null)
{
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key);
jwk.KeyId ??= key.KeyId ?? Guid.NewGuid().ToString("N");
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
var header = new JwtHeader(signingCredentials)
{
["typ"] = "dpop+jwt",
["jwk"] = new Dictionary<string, object?>
{
["kty"] = jwk.Kty,
["crv"] = jwk.Crv,
["x"] = jwk.X,
["y"] = jwk.Y,
["kid"] = jwk.Kid ?? jwk.KeyId
}
};
var payload = new JwtPayload
{
["htm"] = method.ToUpperInvariant(),
["htu"] = url,
["iat"] = issuedAt,
["jti"] = Guid.NewGuid().ToString("N")
};
if (!string.IsNullOrWhiteSpace(nonce))
{
payload["nonce"] = nonce;
}
var token = new JwtSecurityToken(header, payload);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public static X509Certificate2 CreateTestCertificate(string subjectName)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
}
public static ClaimsPrincipal CreatePrincipal(string clientId, string tokenId, string provider, string? subject = null)
{
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
@@ -20,6 +21,7 @@ using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Concelier.Testing;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;
using StellaOps.Cryptography.Audit;
using Xunit;
@@ -62,7 +64,7 @@ public sealed class TokenPersistenceIntegrationTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<ValidateClientCredentialsHandler>.Instance);
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), NullLogger<ValidateClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />

View File

@@ -44,11 +44,15 @@ internal sealed record BootstrapClientRequest
public IReadOnlyCollection<string>? AllowedScopes { get; init; }
public IReadOnlyCollection<string>? AllowedAudiences { get; init; }
public IReadOnlyCollection<string>? RedirectUris { get; init; }
public IReadOnlyCollection<string>? PostLogoutRedirectUris { get; init; }
public IReadOnlyDictionary<string, string?>? Properties { get; init; }
public IReadOnlyCollection<BootstrapClientCertificateBinding>? CertificateBindings { get; init; }
}
internal sealed record BootstrapInviteRequest
@@ -68,6 +72,25 @@ internal sealed record BootstrapInviteRequest
public IReadOnlyDictionary<string, string?>? Metadata { get; init; }
}
internal sealed record BootstrapClientCertificateBinding
{
public string Thumbprint { get; init; } = string.Empty;
public string? SerialNumber { get; init; }
public string? Subject { get; init; }
public string? Issuer { get; init; }
public IReadOnlyCollection<string>? SubjectAlternativeNames { get; init; }
public DateTimeOffset? NotBefore { get; init; }
public DateTimeOffset? NotAfter { get; init; }
public string? Label { get; init; }
}
internal static class BootstrapInviteTypes
{
public const string User = "user";

View File

@@ -15,4 +15,14 @@ internal static class AuthorityOpenIddictConstants
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint";
internal const string SenderConstraintProperty = "authority:sender_constraint";
internal const string DpopKeyThumbprintProperty = "authority:dpop_thumbprint";
internal const string DpopProofJwtIdProperty = "authority:dpop_jti";
internal const string DpopIssuedAtProperty = "authority:dpop_iat";
internal const string DpopConsumedNonceProperty = "authority:dpop_nonce";
internal const string ConfirmationClaimType = "cnf";
internal const string SenderConstraintClaimType = "authority_sender_constraint";
internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint";
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
}

View File

@@ -5,6 +5,8 @@ using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
@@ -18,6 +20,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -30,6 +33,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
private readonly IAuthEventSink auditSink;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly TimeProvider timeProvider;
private readonly IAuthorityClientCertificateValidator certificateValidator;
private readonly IHttpContextAccessor httpContextAccessor;
private readonly ILogger<ValidateClientCredentialsHandler> logger;
public ValidateClientCredentialsHandler(
@@ -39,6 +44,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
IAuthEventSink auditSink,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
TimeProvider timeProvider,
IAuthorityClientCertificateValidator certificateValidator,
IHttpContextAccessor httpContextAccessor,
ILogger<ValidateClientCredentialsHandler> logger)
{
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
@@ -47,6 +54,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
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.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -111,7 +120,44 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] = string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
var existingSenderConstraint = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var senderConstraintValue) && senderConstraintValue is string existingConstraint
? existingConstraint
: null;
var normalizedSenderConstraint = !string.IsNullOrWhiteSpace(existingSenderConstraint)
? existingSenderConstraint
: ClientCredentialHandlerHelpers.NormalizeSenderConstraint(document);
if (!string.IsNullOrWhiteSpace(normalizedSenderConstraint))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = normalizedSenderConstraint;
}
if (string.Equals(normalizedSenderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null)
{
context.Reject(OpenIddictConstants.Errors.ServerError, "HTTP context unavailable for mTLS validation.");
logger.LogWarning("Client credentials validation failed for {ClientId}: HTTP context unavailable for mTLS validation.", context.ClientId);
return;
}
var validation = await certificateValidator.ValidateAsync(httpContext, document, context.CancellationToken).ConfigureAwait(false);
if (!validation.Succeeded)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, validation.Error ?? "Client certificate validation failed.");
logger.LogWarning("Client credentials validation failed for {ClientId}: {Reason}.", context.ClientId, validation.Error ?? "certificate_invalid");
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = AuthoritySenderConstraintKinds.Mtls;
context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty] = validation.ConfirmationThumbprint;
context.Transaction.Properties[AuthorityOpenIddictConstants.MtlsCertificateHexProperty] = validation.HexThumbprint;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] =
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
IIdentityProviderPlugin? provider = null;
if (!string.IsNullOrWhiteSpace(document.Plugin))
@@ -278,6 +324,32 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
return;
}
var configuredAudiences = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
if (configuredAudiences.Count > 0)
{
if (context.Request.Resources is ICollection<string> resources && configuredAudiences.Count > 0)
{
foreach (var audience in configuredAudiences)
{
if (!resources.Contains(audience))
{
resources.Add(audience);
}
}
}
if (context.Request.Audiences is ICollection<string> audiencesCollection)
{
foreach (var audience in configuredAudiences)
{
if (!audiencesCollection.Contains(audience))
{
audiencesCollection.Add(audience);
}
}
}
}
var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, document.ClientId));
identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, document.ClientId));
@@ -322,6 +394,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
activity?.SetTag("authority.identity_provider", provider.Name);
}
ApplySenderConstraintClaims(context, identity, document);
var principal = new ClaimsPrincipal(identity);
var grantedScopes = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientGrantedScopesProperty, out var scopesValue) &&
@@ -338,6 +412,11 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
principal.SetScopes(Array.Empty<string>());
}
if (configuredAudiences.Count > 0)
{
principal.SetAudiences(configuredAudiences);
}
if (provider is not null && descriptor is not null)
{
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user: null, descriptor);
@@ -420,10 +499,95 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
ExpiresAt = expiresAt
};
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var constraintObj) &&
constraintObj is string senderConstraint &&
!string.IsNullOrWhiteSpace(senderConstraint))
{
record.SenderConstraint = senderConstraint;
}
string? senderThumbprint = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var dpopThumbprintObj) &&
dpopThumbprintObj is string dpopThumbprint &&
!string.IsNullOrWhiteSpace(dpopThumbprint))
{
senderThumbprint = dpopThumbprint;
}
else if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) &&
mtlsThumbprintObj is string mtlsThumbprint &&
!string.IsNullOrWhiteSpace(mtlsThumbprint))
{
senderThumbprint = mtlsThumbprint;
}
if (senderThumbprint is not null)
{
record.SenderKeyThumbprint = senderThumbprint;
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
nonceObj is string nonce &&
!string.IsNullOrWhiteSpace(nonce))
{
record.SenderNonce = nonce;
}
await tokenStore.InsertAsync(record, context.CancellationToken, session).ConfigureAwait(false);
context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record;
activity?.SetTag("authority.token_id", tokenId);
}
private static void ApplySenderConstraintClaims(
OpenIddictServerEvents.HandleTokenRequestContext context,
ClaimsIdentity identity,
AuthorityClientDocument document)
{
_ = document;
if (!context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var constraintObj) ||
constraintObj is not string senderConstraint ||
string.IsNullOrWhiteSpace(senderConstraint))
{
return;
}
var normalized = senderConstraint.Trim().ToLowerInvariant();
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = normalized;
identity.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, normalized);
switch (normalized)
{
case AuthoritySenderConstraintKinds.Dpop:
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopKeyThumbprintProperty, out var thumbprintObj) &&
thumbprintObj is string thumbprint &&
!string.IsNullOrWhiteSpace(thumbprint))
{
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
{
["jkt"] = thumbprint
});
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
}
break;
case AuthoritySenderConstraintKinds.Mtls:
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.MtlsCertificateThumbprintProperty, out var mtlsThumbprintObj) &&
mtlsThumbprintObj is string mtlsThumbprint &&
!string.IsNullOrWhiteSpace(mtlsThumbprint))
{
var confirmation = JsonSerializer.Serialize(new Dictionary<string, string>
{
["x5t#S256"] = mtlsThumbprint
});
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
}
break;
}
}
}
internal static class ClientCredentialHandlerHelpers
@@ -491,4 +655,20 @@ internal static class ClientCredentialHandlerHelpers
return false;
}
}
public static string? NormalizeSenderConstraint(AuthorityClientDocument document)
{
if (!string.IsNullOrWhiteSpace(document.SenderConstraint))
{
return document.SenderConstraint.Trim().ToLowerInvariant();
}
if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var value) &&
!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant();
}
return null;
}
}

View File

@@ -0,0 +1,643 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Configuration;
using StellaOps.Cryptography.Audit;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
{
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly IAuthorityClientStore clientStore;
private readonly IDpopProofValidator proofValidator;
private readonly IDpopNonceStore nonceStore;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthEventSink auditSink;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<ValidateDpopProofHandler> logger;
public ValidateDpopProofHandler(
StellaOpsAuthorityOptions authorityOptions,
IAuthorityClientStore clientStore,
IDpopProofValidator proofValidator,
IDpopNonceStore nonceStore,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthEventSink auditSink,
TimeProvider clock,
ActivitySource activitySource,
ILogger<ValidateDpopProofHandler> logger)
{
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.proofValidator = proofValidator ?? throw new ArgumentNullException(nameof(proofValidator));
this.nonceStore = nonceStore ?? throw new ArgumentNullException(nameof(nonceStore));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Request.IsClientCredentialsGrantType())
{
return;
}
using var activity = activitySource.StartActivity("authority.token.validate_dpop", ActivityKind.Internal);
activity?.SetTag("authority.endpoint", "/token");
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials);
var clientId = context.ClientId ?? context.Request.ClientId;
if (string.IsNullOrWhiteSpace(clientId))
{
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
var senderConstraintOptions = authorityOptions.Security.SenderConstraints;
AuthorityClientDocument? clientDocument = await ResolveClientAsync(context, clientId, activity, cancel: context.CancellationToken).ConfigureAwait(false);
if (clientDocument is null)
{
return;
}
var senderConstraint = NormalizeSenderConstraint(clientDocument);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientSenderConstraintProperty] = senderConstraint;
if (!string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Dpop, StringComparison.Ordinal))
{
return;
}
var configuredAudiences = EnsureRequestAudiences(context.Request, clientDocument);
if (!senderConstraintOptions.Dpop.Enabled)
{
logger.LogError("Client {ClientId} requires DPoP but server-side configuration has DPoP disabled.", clientId);
context.Reject(OpenIddictConstants.Errors.ServerError, "DPoP authentication is not enabled.");
await WriteAuditAsync(context, clientDocument, AuthEventOutcome.Failure, "DPoP disabled server-side.", null, null, null, "authority.dpop.proof.disabled").ConfigureAwait(false);
return;
}
metadataAccessor.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
activity?.SetTag("authority.sender_constraint", AuthoritySenderConstraintKinds.Dpop);
HttpRequest? httpRequest = null;
HttpResponse? httpResponse = null;
if (context.Transaction.Properties.TryGetValue(typeof(HttpContext).FullName!, out var httpContextProperty) &&
httpContextProperty is HttpContext capturedContext)
{
httpRequest = capturedContext.Request;
httpResponse = capturedContext.Response;
}
if (httpRequest is null)
{
context.Reject(OpenIddictConstants.Errors.ServerError, "Unable to access HTTP context for DPoP validation.");
logger.LogError("DPoP validation aborted for {ClientId}: HTTP request not available via transaction.", clientId);
await WriteAuditAsync(context, clientDocument, AuthEventOutcome.Failure, "HTTP request unavailable for DPoP.", null, null, null, "authority.dpop.proof.error").ConfigureAwait(false);
return;
}
if (!httpRequest.Headers.TryGetValue("DPoP", out StringValues proofHeader) || StringValues.IsNullOrEmpty(proofHeader))
{
logger.LogWarning("Missing DPoP header for client credentials request from {ClientId}.", clientId);
await ChallengeNonceAsync(
context,
clientDocument,
audience: null,
thumbprint: null,
reasonCode: "missing_proof",
description: "DPoP proof is required.",
senderConstraintOptions,
httpResponse).ConfigureAwait(false);
return;
}
var proof = proofHeader.ToString();
var requestUri = BuildRequestUri(httpRequest);
var validationResult = await proofValidator.ValidateAsync(
proof,
httpRequest.Method,
requestUri,
cancellationToken: context.CancellationToken).ConfigureAwait(false);
if (!validationResult.IsValid)
{
var error = string.IsNullOrWhiteSpace(validationResult.ErrorDescription)
? "DPoP proof validation failed."
: validationResult.ErrorDescription;
logger.LogWarning("DPoP proof validation failed for client {ClientId}: {Reason}.", clientId, error);
await ChallengeNonceAsync(
context,
clientDocument,
audience: null,
thumbprint: null,
reasonCode: validationResult.ErrorCode ?? "invalid_proof",
description: error,
senderConstraintOptions,
httpResponse).ConfigureAwait(false);
return;
}
if (validationResult.PublicKey is not Microsoft.IdentityModel.Tokens.JsonWebKey jwk)
{
logger.LogWarning("DPoP proof for {ClientId} did not expose a JSON Web Key.", clientId);
await ChallengeNonceAsync(
context,
clientDocument,
audience: null,
thumbprint: null,
reasonCode: "invalid_key",
description: "DPoP proof must embed a JSON Web Key.",
senderConstraintOptions,
httpResponse).ConfigureAwait(false);
return;
}
object rawThumbprint = jwk.ComputeJwkThumbprint();
string thumbprint;
if (rawThumbprint is string value && !string.IsNullOrWhiteSpace(value))
{
thumbprint = value;
}
else if (rawThumbprint is byte[] bytes)
{
thumbprint = Base64UrlEncoder.Encode(bytes);
}
else
{
throw new InvalidOperationException("DPoP JWK thumbprint could not be computed.");
}
context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty] = AuthoritySenderConstraintKinds.Dpop;
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopKeyThumbprintProperty] = thumbprint;
if (!string.IsNullOrWhiteSpace(validationResult.JwtId))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopProofJwtIdProperty] = validationResult.JwtId;
}
if (validationResult.IssuedAt is { } issuedAt)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopIssuedAtProperty] = issuedAt;
}
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
var requiredAudience = ResolveNonceAudience(context.Request, nonceOptions, configuredAudiences);
if (nonceOptions.Enabled && requiredAudience is not null)
{
activity?.SetTag("authority.dpop_nonce_audience", requiredAudience);
var suppliedNonce = validationResult.Nonce;
if (string.IsNullOrWhiteSpace(suppliedNonce))
{
logger.LogInformation("DPoP nonce challenge issued to {ClientId} for audience {Audience}: nonce missing.", clientId, requiredAudience);
await ChallengeNonceAsync(
context,
clientDocument,
requiredAudience,
thumbprint,
"nonce_missing",
"DPoP nonce is required for this audience.",
senderConstraintOptions,
httpResponse).ConfigureAwait(false);
return;
}
var consumeResult = await nonceStore.TryConsumeAsync(
suppliedNonce,
requiredAudience,
clientDocument.ClientId,
thumbprint,
context.CancellationToken).ConfigureAwait(false);
switch (consumeResult.Status)
{
case DpopNonceConsumeStatus.Success:
context.Transaction.Properties[AuthorityOpenIddictConstants.DpopConsumedNonceProperty] = suppliedNonce;
break;
case DpopNonceConsumeStatus.Expired:
logger.LogInformation("DPoP nonce expired for {ClientId} and audience {Audience}.", clientId, requiredAudience);
await ChallengeNonceAsync(
context,
clientDocument,
requiredAudience,
thumbprint,
"nonce_expired",
"DPoP nonce has expired. Retry with a fresh nonce.",
senderConstraintOptions,
httpResponse).ConfigureAwait(false);
return;
default:
logger.LogInformation("DPoP nonce invalid for {ClientId} and audience {Audience}.", clientId, requiredAudience);
await ChallengeNonceAsync(
context,
clientDocument,
requiredAudience,
thumbprint,
"nonce_invalid",
"DPoP nonce is invalid. Request a new nonce and retry.",
senderConstraintOptions,
httpResponse).ConfigureAwait(false);
return;
}
}
await WriteAuditAsync(
context,
clientDocument,
AuthEventOutcome.Success,
"DPoP proof validated.",
thumbprint,
validationResult,
requiredAudience,
"authority.dpop.proof.valid")
.ConfigureAwait(false);
logger.LogInformation("DPoP proof validated for client {ClientId}.", clientId);
}
private async ValueTask<AuthorityClientDocument?> ResolveClientAsync(
OpenIddictServerEvents.ValidateTokenRequestContext context,
string clientId,
Activity? activity,
CancellationToken cancel)
{
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var value) &&
value is AuthorityClientDocument cached)
{
activity?.SetTag("authority.client_id", cached.ClientId);
return cached;
}
var document = await clientStore.FindByClientIdAsync(clientId, cancel).ConfigureAwait(false);
if (document is not null)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document;
activity?.SetTag("authority.client_id", document.ClientId);
}
return document;
}
private static string? NormalizeSenderConstraint(AuthorityClientDocument document)
{
if (!string.IsNullOrWhiteSpace(document.SenderConstraint))
{
return document.SenderConstraint.Trim().ToLowerInvariant();
}
if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var value) &&
!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant();
}
return null;
}
private static IReadOnlyList<string> EnsureRequestAudiences(OpenIddictRequest? request, AuthorityClientDocument document)
{
if (request is null)
{
return Array.Empty<string>();
}
var configuredAudiences = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
if (configuredAudiences.Count == 0)
{
return configuredAudiences;
}
if (request.Resources is ICollection<string> resources)
{
foreach (var audience in configuredAudiences)
{
if (!resources.Contains(audience))
{
resources.Add(audience);
}
}
}
if (request.Audiences is ICollection<string> audiencesCollection)
{
foreach (var audience in configuredAudiences)
{
if (!audiencesCollection.Contains(audience))
{
audiencesCollection.Add(audience);
}
}
}
return configuredAudiences;
}
private static Uri BuildRequestUri(HttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var url = request.GetDisplayUrl();
return new Uri(url, UriKind.Absolute);
}
private static string? ResolveNonceAudience(OpenIddictRequest request, AuthorityDpopNonceOptions nonceOptions, IReadOnlyList<string> configuredAudiences)
{
if (!nonceOptions.Enabled || request is null)
{
return null;
}
if (request.Resources is not null)
{
foreach (var resource in request.Resources)
{
if (string.IsNullOrWhiteSpace(resource))
{
continue;
}
var normalized = resource.Trim();
if (nonceOptions.RequiredAudiences.Contains(normalized))
{
return normalized;
}
}
}
if (request.Audiences is not null)
{
foreach (var audience in request.Audiences)
{
if (string.IsNullOrWhiteSpace(audience))
{
continue;
}
var normalized = audience.Trim();
if (nonceOptions.RequiredAudiences.Contains(normalized))
{
return normalized;
}
}
}
if (configuredAudiences is { Count: > 0 })
{
foreach (var audience in configuredAudiences)
{
if (string.IsNullOrWhiteSpace(audience))
{
continue;
}
var normalized = audience.Trim();
if (nonceOptions.RequiredAudiences.Contains(normalized))
{
return normalized;
}
}
}
return null;
}
private async ValueTask ChallengeNonceAsync(
OpenIddictServerEvents.ValidateTokenRequestContext context,
AuthorityClientDocument clientDocument,
string? audience,
string? thumbprint,
string reasonCode,
string description,
AuthoritySenderConstraintOptions senderConstraintOptions,
HttpResponse? httpResponse)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, description);
metadataAccessor.SetTag("authority.dpop_result", reasonCode);
string? issuedNonce = null;
DateTimeOffset? expiresAt = null;
if (audience is not null && thumbprint is not null && senderConstraintOptions.Dpop.Nonce.Enabled)
{
var issuance = await nonceStore.IssueAsync(
audience,
clientDocument.ClientId,
thumbprint,
senderConstraintOptions.Dpop.Nonce.Ttl,
senderConstraintOptions.Dpop.Nonce.MaxIssuancePerMinute,
context.CancellationToken).ConfigureAwait(false);
if (issuance.Status == DpopNonceIssueStatus.Success)
{
issuedNonce = issuance.Nonce;
expiresAt = issuance.ExpiresAt;
}
else
{
logger.LogWarning("Unable to issue DPoP nonce for {ClientId} (audience {Audience}): {Status}.", clientDocument.ClientId, audience, issuance.Status);
}
}
if (httpResponse is not null)
{
httpResponse.Headers["WWW-Authenticate"] = BuildAuthenticateHeader(reasonCode, description, issuedNonce);
if (!string.IsNullOrWhiteSpace(issuedNonce))
{
httpResponse.Headers["DPoP-Nonce"] = issuedNonce;
}
}
await WriteAuditAsync(
context,
clientDocument,
AuthEventOutcome.Failure,
description,
thumbprint,
validationResult: null,
audience,
"authority.dpop.proof.challenge",
reasonCode,
issuedNonce,
expiresAt)
.ConfigureAwait(false);
}
private static string BuildAuthenticateHeader(string reasonCode, string description, string? nonce)
{
var parameters = new Dictionary<string, string?>
{
["error"] = string.Equals(reasonCode, "nonce_missing", StringComparison.OrdinalIgnoreCase)
? "use_dpop_nonce"
: "invalid_dpop_proof",
["error_description"] = description
};
if (!string.IsNullOrWhiteSpace(nonce))
{
parameters["dpop-nonce"] = nonce;
}
var segments = new List<string>();
foreach (var kvp in parameters)
{
if (kvp.Value is null)
{
continue;
}
segments.Add($"{kvp.Key}=\"{EscapeHeaderValue(kvp.Value)}\"");
}
return segments.Count > 0
? $"DPoP {string.Join(", ", segments)}"
: "DPoP";
static string EscapeHeaderValue(string value)
=> value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal);
}
private async ValueTask WriteAuditAsync(
OpenIddictServerEvents.ValidateTokenRequestContext context,
AuthorityClientDocument clientDocument,
AuthEventOutcome outcome,
string reason,
string? thumbprint,
DpopValidationResult? validationResult,
string? audience,
string eventType,
string? reasonCode = null,
string? issuedNonce = null,
DateTimeOffset? nonceExpiresAt = null)
{
var metadata = metadataAccessor.GetMetadata();
var properties = new List<AuthEventProperty>
{
new()
{
Name = "sender.constraint",
Value = ClassifiedString.Public(AuthoritySenderConstraintKinds.Dpop)
}
};
if (!string.IsNullOrWhiteSpace(reasonCode))
{
properties.Add(new AuthEventProperty
{
Name = "dpop.reason_code",
Value = ClassifiedString.Public(reasonCode)
});
}
if (!string.IsNullOrWhiteSpace(thumbprint))
{
properties.Add(new AuthEventProperty
{
Name = "dpop.jkt",
Value = ClassifiedString.Public(thumbprint)
});
}
if (validationResult?.JwtId is not null)
{
properties.Add(new AuthEventProperty
{
Name = "dpop.jti",
Value = ClassifiedString.Public(validationResult.JwtId)
});
}
if (validationResult?.IssuedAt is { } issuedAt)
{
properties.Add(new AuthEventProperty
{
Name = "dpop.issued_at",
Value = ClassifiedString.Public(issuedAt.ToString("O", CultureInfo.InvariantCulture))
});
}
if (audience is not null)
{
properties.Add(new AuthEventProperty
{
Name = "dpop.audience",
Value = ClassifiedString.Public(audience)
});
}
if (!string.IsNullOrWhiteSpace(validationResult?.Nonce))
{
properties.Add(new AuthEventProperty
{
Name = "dpop.nonce.presented",
Value = ClassifiedString.Sensitive(validationResult.Nonce)
});
}
if (!string.IsNullOrWhiteSpace(issuedNonce))
{
properties.Add(new AuthEventProperty
{
Name = "dpop.nonce.issued",
Value = ClassifiedString.Sensitive(issuedNonce)
});
}
if (nonceExpiresAt is { } expiresAt)
{
properties.Add(new AuthEventProperty
{
Name = "dpop.nonce.expires_at",
Value = ClassifiedString.Public(expiresAt.ToString("O", CultureInfo.InvariantCulture))
});
}
var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
var record = ClientCredentialsAuditHelper.CreateRecord(
clock,
context.Transaction,
metadata,
clientSecret: null,
outcome,
reason,
clientDocument.ClientId,
providerName: clientDocument.Plugin,
confidential,
requestedScopes: Array.Empty<string>(),
grantedScopes: Array.Empty<string>(),
invalidScope: null,
extraProperties: properties,
eventType: eventType);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
}
}

View File

@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -92,6 +93,33 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
ExpiresAt = TryGetExpiration(principal)
};
var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (!string.IsNullOrWhiteSpace(senderConstraint))
{
document.SenderConstraint = senderConstraint;
}
var confirmation = principal.GetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType);
if (!string.IsNullOrWhiteSpace(confirmation))
{
try
{
using var json = JsonDocument.Parse(confirmation);
if (json.RootElement.TryGetProperty("jkt", out var thumbprintElement))
{
document.SenderKeyThumbprint = thumbprintElement.GetString();
}
else if (json.RootElement.TryGetProperty("x5t#S256", out var certificateThumbprintElement))
{
document.SenderKeyThumbprint = certificateThumbprintElement.GetString();
}
}
catch (JsonException)
{
// Ignore malformed confirmation claims in persistence layer.
}
}
try
{
await tokenStore.InsertAsync(document, cancellationToken, session).ConfigureAwait(false);

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
@@ -16,6 +17,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Authority.Security;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -106,6 +108,11 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
}
}
if (tokenDocument is not null)
{
EnsureSenderConstraintClaims(context.Principal, tokenDocument);
}
if (!context.IsRejected && tokenDocument is not null)
{
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
@@ -272,4 +279,46 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
}
private static void EnsureSenderConstraintClaims(ClaimsPrincipal? principal, AuthorityTokenDocument tokenDocument)
{
if (principal?.Identity is not ClaimsIdentity identity)
{
return;
}
if (!string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) &&
!identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.SenderConstraintClaimType))
{
identity.SetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType, tokenDocument.SenderConstraint);
}
if (identity.HasClaim(claim => claim.Type == AuthorityOpenIddictConstants.ConfirmationClaimType))
{
return;
}
if (string.IsNullOrWhiteSpace(tokenDocument.SenderConstraint) || string.IsNullOrWhiteSpace(tokenDocument.SenderKeyThumbprint))
{
return;
}
string confirmation = tokenDocument.SenderConstraint switch
{
AuthoritySenderConstraintKinds.Dpop => JsonSerializer.Serialize(new Dictionary<string, string>
{
["jkt"] = tokenDocument.SenderKeyThumbprint
}),
AuthoritySenderConstraintKinds.Mtls => JsonSerializer.Serialize(new Dictionary<string, string>
{
["x5t#S256"] = tokenDocument.SenderKeyThumbprint
}),
_ => string.Empty
};
if (!string.IsNullOrEmpty(confirmation))
{
identity.SetClaim(AuthorityOpenIddictConstants.ConfirmationClaimType, confirmation);
}
}
}

View File

@@ -38,8 +38,10 @@ using StellaOps.Authority.Revocation;
using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Security;
#if STELLAOPS_AUTH_SECURITY
using StellaOps.Auth.Security.Dpop;
using StackExchange.Redis;
#endif
var builder = WebApplication.CreateBuilder(args);
@@ -98,6 +100,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
builder.Services.AddSingleton<IAuthorityClientCertificateValidator, AuthorityClientCertificateValidator>();
#if STELLAOPS_AUTH_SECURITY
var senderConstraints = authorityOptions.Security.SenderConstraints;
@@ -119,6 +122,29 @@ builder.Services.AddOptions<DpopValidationOptions>()
builder.Services.TryAddSingleton<IDpopReplayCache>(provider => new InMemoryDpopReplayCache(provider.GetService<TimeProvider>()));
builder.Services.TryAddSingleton<IDpopProofValidator, DpopProofValidator>();
if (string.Equals(senderConstraints.Dpop.Nonce.Store, "redis", StringComparison.OrdinalIgnoreCase))
{
builder.Services.TryAddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(senderConstraints.Dpop.Nonce.RedisConnectionString!));
builder.Services.TryAddSingleton<IDpopNonceStore>(provider =>
{
var multiplexer = provider.GetRequiredService<IConnectionMultiplexer>();
var timeProvider = provider.GetService<TimeProvider>();
return new RedisDpopNonceStore(multiplexer, timeProvider);
});
}
else
{
builder.Services.TryAddSingleton<IDpopNonceStore>(provider =>
{
var timeProvider = provider.GetService<TimeProvider>();
var nonceLogger = provider.GetService<ILogger<InMemoryDpopNonceStore>>();
return new InMemoryDpopNonceStore(timeProvider, nonceLogger);
});
}
builder.Services.AddScoped<ValidateDpopProofHandler>();
#endif
builder.Services.AddRateLimiter(rateLimiterOptions =>
@@ -219,6 +245,13 @@ builder.Services.AddOpenIddict()
aspNetCoreBuilder.DisableTransportSecurityRequirement();
}
#if STELLAOPS_AUTH_SECURITY
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenRequestContext>(descriptor =>
{
descriptor.UseScopedHandler<ValidateDpopProofHandler>();
});
#endif
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenRequestContext>(descriptor =>
{
descriptor.UseScopedHandler<ValidatePasswordGrantHandler>();
@@ -723,6 +756,33 @@ if (authorityOptions.Bootstrap.Enabled)
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(request.Properties, StringComparer.OrdinalIgnoreCase);
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null;
if (request.CertificateBindings is not null)
{
var bindingRegistrations = new List<AuthorityClientCertificateBindingRegistration>(request.CertificateBindings.Count);
foreach (var binding in request.CertificateBindings)
{
if (binding is null || string.IsNullOrWhiteSpace(binding.Thumbprint))
{
await ReleaseInviteAsync("Certificate binding thumbprint is required.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Certificate binding thumbprint is required.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_request", message = "Certificate binding thumbprint is required." });
}
bindingRegistrations.Add(new AuthorityClientCertificateBindingRegistration(
binding.Thumbprint,
binding.SerialNumber,
binding.Subject,
binding.Issuer,
binding.SubjectAlternativeNames,
binding.NotBefore,
binding.NotAfter,
binding.Label));
}
certificateBindings = bindingRegistrations;
}
var registration = new AuthorityClientRegistration(
request.ClientId,
request.Confidential,
@@ -730,9 +790,11 @@ if (authorityOptions.Bootstrap.Enabled)
request.ClientSecret,
request.AllowedGrantTypes ?? Array.Empty<string>(),
request.AllowedScopes ?? Array.Empty<string>(),
request.AllowedAudiences ?? Array.Empty<string>(),
redirectUris,
postLogoutUris,
properties);
properties,
certificateBindings);
var result = await provider.ClientProvisioning.CreateOrUpdateAsync(registration, cancellationToken).ConfigureAwait(false);
@@ -1149,7 +1211,7 @@ static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions option
{
BaseDirectory = basePath,
PluginsDirectory = string.IsNullOrWhiteSpace(pluginDirectory)
? Path.Combine("PluginBinaries", "Authority")
? "StellaOps.Authority.PluginBinaries"
: pluginDirectory,
PrimaryPrefix = "StellaOps.Authority"
};

View File

@@ -0,0 +1,32 @@
using System;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Security;
internal sealed class AuthorityClientCertificateValidationResult
{
private AuthorityClientCertificateValidationResult(bool succeeded, string? confirmationThumbprint, string? hexThumbprint, AuthorityClientCertificateBinding? binding, string? error)
{
Succeeded = succeeded;
ConfirmationThumbprint = confirmationThumbprint;
HexThumbprint = hexThumbprint;
Binding = binding;
Error = error;
}
public bool Succeeded { get; }
public string? ConfirmationThumbprint { get; }
public string? HexThumbprint { get; }
public AuthorityClientCertificateBinding? Binding { get; }
public string? Error { get; }
public static AuthorityClientCertificateValidationResult Success(string confirmationThumbprint, string hexThumbprint, AuthorityClientCertificateBinding binding)
=> new(true, confirmationThumbprint, hexThumbprint, binding, null);
public static AuthorityClientCertificateValidationResult Failure(string error)
=> new(false, null, null, null, error);
}

View File

@@ -0,0 +1,283 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using System.Formats.Asn1;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Authority.Security;
internal sealed class AuthorityClientCertificateValidator : IAuthorityClientCertificateValidator
{
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly TimeProvider timeProvider;
private readonly ILogger<AuthorityClientCertificateValidator> logger;
public AuthorityClientCertificateValidator(
StellaOpsAuthorityOptions authorityOptions,
TimeProvider timeProvider,
ILogger<AuthorityClientCertificateValidator> logger)
{
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(client);
var certificate = httpContext.Connection.ClientCertificate;
if (certificate is null)
{
logger.LogWarning("mTLS validation failed for {ClientId}: no client certificate present.", client.ClientId);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("client_certificate_required"));
}
var mtlsOptions = authorityOptions.Security.SenderConstraints.Mtls;
var requiresChain = mtlsOptions.RequireChainValidation || mtlsOptions.AllowedCertificateAuthorities.Count > 0;
X509Chain? chain = null;
var chainBuilt = false;
try
{
if (requiresChain)
{
chain = CreateChain();
chainBuilt = TryBuildChain(chain, certificate);
if (mtlsOptions.RequireChainValidation && !chainBuilt)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate chain validation failed.", client.ClientId);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_chain_invalid"));
}
}
var now = timeProvider.GetUtcNow();
if (now < certificate.NotBefore || now > certificate.NotAfter)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate outside validity window (notBefore={NotBefore:o}, notAfter={NotAfter:o}).", client.ClientId, certificate.NotBefore, certificate.NotAfter);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_expired"));
}
if (mtlsOptions.NormalizedSubjectPatterns.Count > 0 &&
!mtlsOptions.NormalizedSubjectPatterns.Any(pattern => pattern.IsMatch(certificate.Subject)))
{
logger.LogWarning("mTLS validation failed for {ClientId}: subject {Subject} did not match allowed patterns.", client.ClientId, certificate.Subject);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_subject_mismatch"));
}
var subjectAlternativeNames = GetSubjectAlternativeNames(certificate);
if (mtlsOptions.AllowedSanTypes.Count > 0)
{
if (subjectAlternativeNames.Count == 0)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate does not contain subject alternative names.", client.ClientId);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_san_missing"));
}
if (subjectAlternativeNames.Any(san => !mtlsOptions.AllowedSanTypes.Contains(san.Type)))
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate SAN types [{Types}] not allowed.", client.ClientId, string.Join(",", subjectAlternativeNames.Select(san => san.Type)));
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_san_type"));
}
if (!subjectAlternativeNames.Any(san => mtlsOptions.AllowedSanTypes.Contains(san.Type)))
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate SANs did not include any of the required types.", client.ClientId);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_san_missing_required"));
}
}
if (mtlsOptions.AllowedCertificateAuthorities.Count > 0)
{
var allowedCas = mtlsOptions.AllowedCertificateAuthorities
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var matchedCa = false;
if (chainBuilt && chain is not null)
{
foreach (var element in chain.ChainElements.Cast<X509ChainElement>().Skip(1))
{
if (allowedCas.Contains(element.Certificate.Subject))
{
matchedCa = true;
break;
}
}
}
if (!matchedCa && allowedCas.Contains(certificate.Issuer))
{
matchedCa = true;
}
if (!matchedCa)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate issuer {Issuer} is not allow-listed.", client.ClientId, certificate.Issuer);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_ca_untrusted"));
}
}
if (client.CertificateBindings.Count == 0)
{
logger.LogWarning("mTLS validation failed for {ClientId}: no certificate bindings registered for client.", client.ClientId);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_missing"));
}
var certificateHash = certificate.GetCertHash(HashAlgorithmName.SHA256);
var hexThumbprint = Convert.ToHexString(certificateHash);
var base64Thumbprint = Base64UrlEncoder.Encode(certificateHash);
var binding = client.CertificateBindings.FirstOrDefault(b => string.Equals(b.Thumbprint, hexThumbprint, StringComparison.OrdinalIgnoreCase));
if (binding is null)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate thumbprint {Thumbprint} not registered.", client.ClientId, hexThumbprint);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_unbound"));
}
if (binding.NotBefore is { } bindingNotBefore)
{
var effectiveNotBefore = bindingNotBefore - mtlsOptions.RotationGrace;
if (now < effectiveNotBefore)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding not active until {NotBefore:o} (grace applied).", client.ClientId, bindingNotBefore);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_inactive"));
}
}
if (binding.NotAfter is { } bindingNotAfter)
{
var effectiveNotAfter = bindingNotAfter + mtlsOptions.RotationGrace;
if (now > effectiveNotAfter)
{
logger.LogWarning("mTLS validation failed for {ClientId}: certificate binding expired at {NotAfter:o} (grace applied).", client.ClientId, bindingNotAfter);
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Failure("certificate_binding_expired"));
}
}
return ValueTask.FromResult(AuthorityClientCertificateValidationResult.Success(base64Thumbprint, hexThumbprint, binding));
}
finally
{
chain?.Dispose();
}
}
private static X509Chain CreateChain()
=> new()
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
RevocationFlag = X509RevocationFlag.ExcludeRoot,
VerificationFlags = X509VerificationFlags.IgnoreWrongUsage
}
};
private bool TryBuildChain(X509Chain chain, X509Certificate2 certificate)
{
try
{
return chain.Build(certificate);
}
catch (Exception ex)
{
logger.LogWarning(ex, "mTLS chain validation threw an exception.");
return false;
}
}
private static IReadOnlyList<(string Type, string Value)> GetSubjectAlternativeNames(X509Certificate2 certificate)
{
foreach (var extension in certificate.Extensions)
{
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
{
continue;
}
try
{
var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER);
var sequence = reader.ReadSequence();
var results = new List<(string, string)>();
while (sequence.HasData)
{
var tag = sequence.PeekTag();
if (tag.TagClass != TagClass.ContextSpecific)
{
sequence.ReadEncodedValue();
continue;
}
switch (tag.TagValue)
{
case 2:
{
var dns = sequence.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 2));
results.Add(("dns", dns));
break;
}
case 6:
{
var uri = sequence.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6));
results.Add(("uri", uri));
break;
}
case 7:
{
var bytes = sequence.ReadOctetString(new Asn1Tag(TagClass.ContextSpecific, 7));
var ip = new IPAddress(bytes).ToString();
results.Add(("ip", ip));
break;
}
default:
sequence.ReadEncodedValue();
break;
}
}
return results;
}
catch
{
return Array.Empty<(string, string)>();
}
}
return Array.Empty<(string, string)>();
}
private bool ValidateCertificateChain(X509Certificate2 certificate)
{
using var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
RevocationFlag = X509RevocationFlag.ExcludeRoot,
VerificationFlags = X509VerificationFlags.IgnoreWrongUsage
}
};
try
{
return chain.Build(certificate);
}
catch (Exception ex)
{
logger.LogWarning(ex, "mTLS chain validation threw an exception.");
return false;
}
}
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Authority.Security;
/// <summary>
/// Canonical string identifiers for Authority sender-constraint policies.
/// </summary>
internal static class AuthoritySenderConstraintKinds
{
internal const string Dpop = "dpop";
internal const string Mtls = "mtls";
}

View File

@@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Security;
internal interface IAuthorityClientCertificateValidator
{
ValueTask<AuthorityClientCertificateValidationResult> ValidateAsync(HttpContext httpContext, AuthorityClientDocument client, CancellationToken cancellationToken);
}

View File

@@ -17,6 +17,7 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />

View File

@@ -20,10 +20,13 @@
| AUTHCORE-STORAGE-DEVICE-TOKENS | DONE (2025-10-14) | Authority Core, Storage Guild | AUTHCORE-BUILD-OPENIDDICT | Reintroduce `AuthorityTokenDeviceDocument` + projections removed during refactor so storage layer compiles. | ✅ Document type restored with mappings/migrations; ✅ Storage tests cover device artifacts; ✅ Authority solution build green. |
| AUTHCORE-BOOTSTRAP-INVITES | DONE (2025-10-14) | Authority Core, DevOps | AUTHCORE-STORAGE-DEVICE-TOKENS | Wire bootstrap invite cleanup service against restored document schema and re-enable lifecycle tests. | ✅ `BootstrapInviteCleanupService` passes integration tests; ✅ Operator guide updated if behavior changes; ✅ Build/test matrices green. |
| AUTHSTORAGE-MONGO-08-001 | DONE (2025-10-19) | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
| AUTH-DPOP-11-001 | DOING (2025-10-19) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | • DPoP proof validator verifies method/uri/hash, jwk thumbprint, and replay nonce per spec<br>• Nonce issuance endpoint integrated with audit + rate limits; high-value audiences enforce nonce requirement<br>• Integration tests cover success/failure paths (expired nonce, replay, invalid proof) and docs outline operator configuration |
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Client registration stores certificate bindings and enforces SAN/thumbprint validation during token issuance<br>• Token endpoint returns certificate-bound access tokens + PoP proof metadata; introspection reflects binding state<br>• End-to-end tests validate successful mTLS issuance, rejection of unbound certs, and docs capture configuration/rotation guidance |
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
> Remark (2025-10-19, AUTH-DPOP-11-001): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. Design blueprint recorded in `docs/dev/authority-dpop-mtls-plan.md`.
> Remark (2025-10-19, AUTH-MTLS-11-002): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. mTLS flow design captured in `docs/dev/authority-dpop-mtls-plan.md`.
| AUTH-PLUGIN-COORD-08-002 | DOING (2025-10-19) | Authority Core, Plugin Platform Guild | PLUGIN-DI-08-001 | Coordinate scoped-service adoption for Authority plug-in registrars and background jobs ahead of PLUGIN-DI-08-002 implementation. | ✅ Workshop locked for 2025-10-20 15:0016:00UTC; ✅ Pre-read checklist in `docs/dev/authority-plugin-di-coordination.md`; ✅ Follow-up tasks captured in module backlogs before code changes begin. |
| AUTH-DPOP-11-001 | DOING (2025-10-19) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | • Proof handler validates method/uri/hash + replay; nonce issuing/consumption implemented for in-memory + Redis stores<br>• Client credential path stamps `cnf.jkt` and persists sender metadata<br>• Remaining: finalize Redis configuration surface (docs/sample config), unskip nonce-challenge regression once HTTP pipeline emits high-value audiences, refresh operator docs |
> Remark (2025-10-19): DPoP handler now seeds request resources/audiences from client metadata; nonce challenge integration test re-enabled (still requires full suite once Concelier build restored).
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Certificate validator scaffold plus cnf stamping present; tokens persist sender thumbprints<br>• Remaining: provisioning/storage for certificate bindings, SAN/CA validation, introspection propagation, integration tests/docs before marking DONE |
> Remark (2025-10-19): Client provisioning accepts certificate bindings; validator enforces SAN types/CA allow-list with rotation grace; mtls integration tests updated (full suite still blocked by upstream build).
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Prerequisites re-checked (none outstanding). Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
> Remark (2025-10-19, AUTH-DPOP-11-001): Handler, nonce store, and persistence hooks merged; Redis-backed configuration + end-to-end nonce enforcement still open. Full solution test blocked by `StellaOps.Concelier.Storage.Mongo` compile errors.
> Remark (2025-10-19, AUTH-MTLS-11-002): Certificate validator + cnf stamping delivered; binding storage, CA/SAN validation, integration suites outstanding before status can move to DONE.
> Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic.