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:
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide (AUTHPLUG-DOCS-01-001). | Docs team delivers copy-edit + exported diagrams; PR merged. |
|
||||
| SEC1.PLG | DONE (2025-10-11) | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
|
||||
| SEC1.OPT | DONE (2025-10-11) | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
|
||||
| SEC2.PLG | 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 Wave 0B 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 | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
|
||||
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
|
||||
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
|
||||
@@ -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): Wave 0A 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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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:00–16:00 UTC; ✅ 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.
|
||||
|
||||
Reference in New Issue
Block a user