Resolve Concelier/Excititor merge conflicts
This commit is contained in:
@@ -1,74 +1,74 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
public class StellaOpsPrincipalBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
|
||||
{
|
||||
var builder = new StellaOpsPrincipalBuilder()
|
||||
.WithScopes(new[] { "Feedser.Jobs.Trigger", " feedser.jobs.trigger ", "AUTHORITY.USERS.MANAGE" })
|
||||
.WithAudiences(new[] { " api://feedser ", "api://cli", "api://feedser" });
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "authority.users.manage", "feedser.jobs.trigger" },
|
||||
builder.NormalizedScopes);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "api://cli", "api://feedser" },
|
||||
builder.Audiences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var builder = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject(" user-1 ")
|
||||
.WithClientId(" cli-01 ")
|
||||
.WithTenant(" default ")
|
||||
.WithName(" Jane Doe ")
|
||||
.WithIdentityProvider(" internal ")
|
||||
.WithSessionId(" session-123 ")
|
||||
.WithTokenId(Guid.NewGuid().ToString("N"))
|
||||
.WithAuthenticationMethod("password")
|
||||
.WithAuthenticationType(" custom ")
|
||||
.WithScopes(new[] { "Feedser.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
|
||||
.WithAudience(" api://feedser ")
|
||||
.WithIssuedAt(now)
|
||||
.WithExpires(now.AddMinutes(5))
|
||||
.AddClaim(" custom ", " value ");
|
||||
|
||||
var principal = builder.Build();
|
||||
var identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
|
||||
|
||||
Assert.Equal("custom", identity.AuthenticationType);
|
||||
Assert.Equal("Jane Doe", identity.Name);
|
||||
Assert.Equal("user-1", principal.FindFirstValue(StellaOpsClaimTypes.Subject));
|
||||
Assert.Equal("cli-01", principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
|
||||
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
|
||||
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
|
||||
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
|
||||
Assert.Equal("value", principal.FindFirstValue("custom"));
|
||||
|
||||
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();
|
||||
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, scopeClaims);
|
||||
|
||||
var scopeList = principal.FindFirstValue(StellaOpsClaimTypes.Scope);
|
||||
Assert.Equal("authority.users.manage feedser.jobs.trigger", scopeList);
|
||||
|
||||
var audienceClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.Audience).Select(claim => claim.Value).ToArray();
|
||||
Assert.Equal(new[] { "api://feedser" }, audienceClaims);
|
||||
|
||||
var issuedAt = principal.FindFirstValue("iat");
|
||||
Assert.Equal(now.ToUnixTimeSeconds().ToString(), issuedAt);
|
||||
|
||||
var expires = principal.FindFirstValue("exp");
|
||||
Assert.Equal(now.AddMinutes(5).ToUnixTimeSeconds().ToString(), expires);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
public class StellaOpsPrincipalBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
|
||||
{
|
||||
var builder = new StellaOpsPrincipalBuilder()
|
||||
.WithScopes(new[] { "Concelier.Jobs.Trigger", " concelier.jobs.trigger ", "AUTHORITY.USERS.MANAGE" })
|
||||
.WithAudiences(new[] { " api://concelier ", "api://cli", "api://concelier" });
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "authority.users.manage", "concelier.jobs.trigger" },
|
||||
builder.NormalizedScopes);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "api://cli", "api://concelier" },
|
||||
builder.Audiences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var builder = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject(" user-1 ")
|
||||
.WithClientId(" cli-01 ")
|
||||
.WithTenant(" default ")
|
||||
.WithName(" Jane Doe ")
|
||||
.WithIdentityProvider(" internal ")
|
||||
.WithSessionId(" session-123 ")
|
||||
.WithTokenId(Guid.NewGuid().ToString("N"))
|
||||
.WithAuthenticationMethod("password")
|
||||
.WithAuthenticationType(" custom ")
|
||||
.WithScopes(new[] { "Concelier.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
|
||||
.WithAudience(" api://concelier ")
|
||||
.WithIssuedAt(now)
|
||||
.WithExpires(now.AddMinutes(5))
|
||||
.AddClaim(" custom ", " value ");
|
||||
|
||||
var principal = builder.Build();
|
||||
var identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
|
||||
|
||||
Assert.Equal("custom", identity.AuthenticationType);
|
||||
Assert.Equal("Jane Doe", identity.Name);
|
||||
Assert.Equal("user-1", principal.FindFirstValue(StellaOpsClaimTypes.Subject));
|
||||
Assert.Equal("cli-01", principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
|
||||
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
|
||||
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
|
||||
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
|
||||
Assert.Equal("value", principal.FindFirstValue("custom"));
|
||||
|
||||
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();
|
||||
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, scopeClaims);
|
||||
|
||||
var scopeList = principal.FindFirstValue(StellaOpsClaimTypes.Scope);
|
||||
Assert.Equal("authority.users.manage concelier.jobs.trigger", scopeList);
|
||||
|
||||
var audienceClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.Audience).Select(claim => claim.Value).ToArray();
|
||||
Assert.Equal(new[] { "api://concelier" }, audienceClaims);
|
||||
|
||||
var issuedAt = principal.FindFirstValue("iat");
|
||||
Assert.Equal(now.ToUnixTimeSeconds().ToString(), issuedAt);
|
||||
|
||||
var expires = principal.FindFirstValue("exp");
|
||||
Assert.Equal(now.AddMinutes(5).ToUnixTimeSeconds().ToString(), expires);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
public class StellaOpsProblemResultFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthenticationRequired_ReturnsCanonicalProblem()
|
||||
{
|
||||
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
|
||||
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
|
||||
Assert.Equal("https://docs.stella-ops.org/problems/authentication-required", details.Type);
|
||||
Assert.Equal("Authentication required", details.Title);
|
||||
Assert.Equal("/jobs", details.Instance);
|
||||
Assert.Equal("unauthorized", details.Extensions["error"]);
|
||||
Assert.Equal(details.Detail, details.Extensions["error_description"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidToken_UsesProvidedDetail()
|
||||
{
|
||||
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
|
||||
|
||||
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
|
||||
Assert.Equal("expired refresh token", details.Detail);
|
||||
Assert.Equal("invalid_token", details.Extensions["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsufficientScope_AddsScopeExtensions()
|
||||
{
|
||||
var result = StellaOpsProblemResultFactory.InsufficientScope(
|
||||
new[] { StellaOpsScopes.FeedserJobsTrigger },
|
||||
new[] { StellaOpsScopes.AuthorityUsersManage },
|
||||
instance: "/jobs/trigger");
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
|
||||
|
||||
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
|
||||
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
|
||||
Assert.Equal("insufficient_scope", details.Extensions["error"]);
|
||||
Assert.Equal(new[] { StellaOpsScopes.FeedserJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
|
||||
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
|
||||
Assert.Equal("/jobs/trigger", details.Instance);
|
||||
}
|
||||
}
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
public class StellaOpsProblemResultFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthenticationRequired_ReturnsCanonicalProblem()
|
||||
{
|
||||
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
|
||||
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
|
||||
Assert.Equal("https://docs.stella-ops.org/problems/authentication-required", details.Type);
|
||||
Assert.Equal("Authentication required", details.Title);
|
||||
Assert.Equal("/jobs", details.Instance);
|
||||
Assert.Equal("unauthorized", details.Extensions["error"]);
|
||||
Assert.Equal(details.Detail, details.Extensions["error_description"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidToken_UsesProvidedDetail()
|
||||
{
|
||||
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
|
||||
|
||||
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
|
||||
Assert.Equal("expired refresh token", details.Detail);
|
||||
Assert.Equal("invalid_token", details.Extensions["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsufficientScope_AddsScopeExtensions()
|
||||
{
|
||||
var result = StellaOpsProblemResultFactory.InsufficientScope(
|
||||
new[] { StellaOpsScopes.ConcelierJobsTrigger },
|
||||
new[] { StellaOpsScopes.AuthorityUsersManage },
|
||||
instance: "/jobs/trigger");
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
|
||||
|
||||
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
|
||||
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
|
||||
Assert.Equal("insufficient_scope", details.Extensions["error"]);
|
||||
Assert.Equal(new[] { StellaOpsScopes.ConcelierJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
|
||||
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
|
||||
Assert.Equal("/jobs/trigger", details.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical scope names supported by StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope required to trigger Feedser jobs.
|
||||
/// </summary>
|
||||
public const string FeedserJobsTrigger = "feedser.jobs.trigger";
|
||||
|
||||
/// <summary>
|
||||
/// Scope required to manage Feedser merge operations.
|
||||
/// </summary>
|
||||
public const string FeedserMerge = "feedser.merge";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to Authority user management.
|
||||
/// </summary>
|
||||
public const string AuthorityUsersManage = "authority.users.manage";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to Authority client registrations.
|
||||
/// </summary>
|
||||
public const string AuthorityClientsManage = "authority.clients.manage";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Authority audit logs.
|
||||
/// </summary>
|
||||
public const string AuthorityAuditRead = "authority.audit.read";
|
||||
|
||||
/// <summary>
|
||||
/// Synthetic scope representing trusted network bypass.
|
||||
/// </summary>
|
||||
public const string Bypass = "stellaops.bypass";
|
||||
|
||||
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
FeedserJobsTrigger,
|
||||
FeedserMerge,
|
||||
AuthorityUsersManage,
|
||||
AuthorityClientsManage,
|
||||
AuthorityAuditRead,
|
||||
Bypass
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a scope string (trim/convert to lower case).
|
||||
/// </summary>
|
||||
/// <param name="scope">Scope raw value.</param>
|
||||
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
|
||||
public static string? Normalize(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return scope.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
|
||||
/// </summary>
|
||||
public static bool IsKnown(string scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
return KnownScopes.Contains(scope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full set of built-in scopes.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> All => KnownScopes;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical scope names supported by StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope required to trigger Concelier jobs.
|
||||
/// </summary>
|
||||
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
|
||||
|
||||
/// <summary>
|
||||
/// Scope required to manage Concelier merge operations.
|
||||
/// </summary>
|
||||
public const string ConcelierMerge = "concelier.merge";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to Authority user management.
|
||||
/// </summary>
|
||||
public const string AuthorityUsersManage = "authority.users.manage";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to Authority client registrations.
|
||||
/// </summary>
|
||||
public const string AuthorityClientsManage = "authority.clients.manage";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Authority audit logs.
|
||||
/// </summary>
|
||||
public const string AuthorityAuditRead = "authority.audit.read";
|
||||
|
||||
/// <summary>
|
||||
/// Synthetic scope representing trusted network bypass.
|
||||
/// </summary>
|
||||
public const string Bypass = "stellaops.bypass";
|
||||
|
||||
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
ConcelierJobsTrigger,
|
||||
ConcelierMerge,
|
||||
AuthorityUsersManage,
|
||||
AuthorityClientsManage,
|
||||
AuthorityAuditRead,
|
||||
Bypass
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a scope string (trim/convert to lower case).
|
||||
/// </summary>
|
||||
/// <param name="scope">Scope raw value.</param>
|
||||
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
|
||||
public static string? Normalize(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return scope.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
|
||||
/// </summary>
|
||||
public static bool IsKnown(string scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
return KnownScopes.Contains(scope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full set of built-in scopes.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> All => KnownScopes;
|
||||
}
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
using System;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class StellaOpsAuthClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_NormalizesScopes()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
ClientId = "cli",
|
||||
HttpTimeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
options.DefaultScopes.Add(" Feedser.Jobs.Trigger ");
|
||||
options.DefaultScopes.Add("feedser.jobs.trigger");
|
||||
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
|
||||
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
|
||||
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_AuthorityMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NormalizesRetryDelays()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test"
|
||||
};
|
||||
options.RetryDelays.Clear();
|
||||
options.RetryDelays.Add(TimeSpan.Zero);
|
||||
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
|
||||
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
|
||||
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DisabledRetries_ProducesEmptyDelays()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
EnableRetries = false
|
||||
};
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Empty(options.NormalizedRetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_OfflineToleranceNegative()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class StellaOpsAuthClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_NormalizesScopes()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
ClientId = "cli",
|
||||
HttpTimeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
options.DefaultScopes.Add(" Concelier.Jobs.Trigger ");
|
||||
options.DefaultScopes.Add("concelier.jobs.trigger");
|
||||
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
|
||||
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
|
||||
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_AuthorityMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NormalizesRetryDelays()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test"
|
||||
};
|
||||
options.RetryDelays.Clear();
|
||||
options.RetryDelays.Add(TimeSpan.Zero);
|
||||
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
|
||||
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
|
||||
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DisabledRetries_ProducesEmptyDelays()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
EnableRetries = false
|
||||
};
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Empty(options.NormalizedRetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_OfflineToleranceNegative()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class StellaOpsTokenClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RequestPasswordToken_ReturnsResultAndCaches()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
|
||||
var responses = new Queue<HttpResponseMessage>();
|
||||
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
|
||||
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"feedser.jobs.trigger\"}"));
|
||||
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
|
||||
|
||||
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
|
||||
{
|
||||
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
|
||||
return Task.FromResult(responses.Dequeue());
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
ClientId = "cli"
|
||||
};
|
||||
options.DefaultScopes.Add("feedser.jobs.trigger");
|
||||
options.Validate();
|
||||
|
||||
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
|
||||
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
|
||||
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
|
||||
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
|
||||
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
|
||||
|
||||
var result = await client.RequestPasswordTokenAsync("user", "pass");
|
||||
|
||||
Assert.Equal("abc", result.AccessToken);
|
||||
Assert.Contains("feedser.jobs.trigger", result.Scopes);
|
||||
|
||||
await client.CacheTokenAsync("key", result.ToCacheEntry());
|
||||
var cached = await client.GetCachedTokenAsync("key");
|
||||
Assert.NotNull(cached);
|
||||
Assert.Equal("abc", cached!.AccessToken);
|
||||
|
||||
var jwks = await client.GetJsonWebKeySetAsync();
|
||||
Assert.Empty(jwks.Keys);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
{
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> responder(request, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly TOptions value;
|
||||
|
||||
public TestOptionsMonitor(TOptions value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => value;
|
||||
|
||||
public TOptions Get(string? name) => value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class StellaOpsTokenClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RequestPasswordToken_ReturnsResultAndCaches()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
|
||||
var responses = new Queue<HttpResponseMessage>();
|
||||
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
|
||||
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"concelier.jobs.trigger\"}"));
|
||||
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
|
||||
|
||||
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
|
||||
{
|
||||
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
|
||||
return Task.FromResult(responses.Dequeue());
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
ClientId = "cli"
|
||||
};
|
||||
options.DefaultScopes.Add("concelier.jobs.trigger");
|
||||
options.Validate();
|
||||
|
||||
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
|
||||
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
|
||||
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
|
||||
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
|
||||
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
|
||||
|
||||
var result = await client.RequestPasswordTokenAsync("user", "pass");
|
||||
|
||||
Assert.Equal("abc", result.AccessToken);
|
||||
Assert.Contains("concelier.jobs.trigger", result.Scopes);
|
||||
|
||||
await client.CacheTokenAsync("key", result.ToCacheEntry());
|
||||
var cached = await client.GetCachedTokenAsync("key");
|
||||
Assert.NotNull(cached);
|
||||
Assert.Equal("abc", cached!.AccessToken);
|
||||
|
||||
var jwks = await client.GetJsonWebKeySetAsync();
|
||||
Assert.Empty(jwks.Keys);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
{
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> responder(request, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly TOptions value;
|
||||
|
||||
public TestOptionsMonitor(TOptions value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => value;
|
||||
|
||||
public TOptions Get(string? name) => value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:ResourceServer:Authority"] = "https://authority.example",
|
||||
["Authority:ResourceServer:Audiences:0"] = "api://feedser",
|
||||
["Authority:ResourceServer:RequiredScopes:0"] = "feedser.jobs.trigger",
|
||||
["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddStellaOpsResourceServerAuthentication(configuration);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
|
||||
var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
Assert.NotNull(jwtOptions.Authority);
|
||||
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
|
||||
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
|
||||
Assert.Contains("api://feedser", jwtOptions.TokenValidationParameters.ValidAudiences);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
|
||||
Assert.Equal(new[] { "feedser.jobs.trigger" }, resourceOptions.NormalizedScopes);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:ResourceServer:Authority"] = "https://authority.example",
|
||||
["Authority:ResourceServer:Audiences:0"] = "api://concelier",
|
||||
["Authority:ResourceServer:RequiredScopes:0"] = "concelier.jobs.trigger",
|
||||
["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddStellaOpsResourceServerAuthentication(configuration);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
|
||||
var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
Assert.NotNull(jwtOptions.Authority);
|
||||
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
|
||||
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
|
||||
Assert.Contains("api://concelier", jwtOptions.TokenValidationParameters.ValidAudiences);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
|
||||
Assert.Equal(new[] { "concelier.jobs.trigger" }, resourceOptions.NormalizedScopes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class StellaOpsResourceServerOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_NormalisesCollections()
|
||||
{
|
||||
var options = new StellaOpsResourceServerOptions
|
||||
{
|
||||
Authority = "https://authority.stella-ops.test",
|
||||
BackchannelTimeout = TimeSpan.FromSeconds(10),
|
||||
TokenClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
options.Audiences.Add(" api://feedser ");
|
||||
options.Audiences.Add("api://feedser");
|
||||
options.Audiences.Add("api://feedser-admin");
|
||||
|
||||
options.RequiredScopes.Add(" Feedser.Jobs.Trigger ");
|
||||
options.RequiredScopes.Add("feedser.jobs.trigger");
|
||||
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
|
||||
|
||||
options.BypassNetworks.Add("127.0.0.1/32");
|
||||
options.BypassNetworks.Add(" 127.0.0.1/32 ");
|
||||
options.BypassNetworks.Add("::1/128");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
|
||||
Assert.Equal(new[] { "api://feedser", "api://feedser-admin" }, options.Audiences);
|
||||
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
|
||||
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
|
||||
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_AuthorityMissing()
|
||||
{
|
||||
var options = new StellaOpsResourceServerOptions();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class StellaOpsResourceServerOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_NormalisesCollections()
|
||||
{
|
||||
var options = new StellaOpsResourceServerOptions
|
||||
{
|
||||
Authority = "https://authority.stella-ops.test",
|
||||
BackchannelTimeout = TimeSpan.FromSeconds(10),
|
||||
TokenClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
options.Audiences.Add(" api://concelier ");
|
||||
options.Audiences.Add("api://concelier");
|
||||
options.Audiences.Add("api://concelier-admin");
|
||||
|
||||
options.RequiredScopes.Add(" Concelier.Jobs.Trigger ");
|
||||
options.RequiredScopes.Add("concelier.jobs.trigger");
|
||||
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
|
||||
|
||||
options.BypassNetworks.Add("127.0.0.1/32");
|
||||
options.BypassNetworks.Add(" 127.0.0.1/32 ");
|
||||
options.BypassNetworks.Add("::1/128");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
|
||||
Assert.Equal(new[] { "api://concelier", "api://concelier-admin" }, options.Audiences);
|
||||
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
|
||||
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
|
||||
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_AuthorityMissing()
|
||||
{
|
||||
var options = new StellaOpsResourceServerOptions();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class StellaOpsScopeAuthorizationHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenScopePresent()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
var principal = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject("user-1")
|
||||
.WithScopes(new[] { StellaOpsScopes.FeedserJobsTrigger })
|
||||
.Build();
|
||||
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.BypassNetworks.Add("127.0.0.1/32");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
|
||||
{
|
||||
var accessor = new HttpContextAccessor();
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||
accessor.HttpContext = httpContext;
|
||||
|
||||
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
|
||||
|
||||
var handler = new StellaOpsScopeAuthorizationHandler(
|
||||
accessor,
|
||||
bypassEvaluator,
|
||||
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
|
||||
return (handler, accessor);
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
|
||||
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
|
||||
|
||||
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class, new()
|
||||
{
|
||||
private readonly TOptions value;
|
||||
|
||||
public TestOptionsMonitor(Action<TOptions> configure)
|
||||
{
|
||||
value = new TOptions();
|
||||
configure(value);
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => value;
|
||||
|
||||
public TOptions Get(string? name) => value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class StellaOpsScopeAuthorizationHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenScopePresent()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
||||
var principal = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject("user-1")
|
||||
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
|
||||
.Build();
|
||||
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.BypassNetworks.Add("127.0.0.1/32");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
|
||||
{
|
||||
var accessor = new HttpContextAccessor();
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||
accessor.HttpContext = httpContext;
|
||||
|
||||
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
|
||||
|
||||
var handler = new StellaOpsScopeAuthorizationHandler(
|
||||
accessor,
|
||||
bypassEvaluator,
|
||||
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
|
||||
return (handler, accessor);
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
|
||||
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
|
||||
|
||||
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class, new()
|
||||
{
|
||||
private readonly TOptions value;
|
||||
|
||||
public TestOptionsMonitor(Action<TOptions> configure)
|
||||
{
|
||||
value = new TOptions();
|
||||
configure(value);
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => value;
|
||||
|
||||
public TOptions Get(string? name) => value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# StellaOps.Auth.ServerIntegration
|
||||
|
||||
ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**:
|
||||
|
||||
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
|
||||
- Network bypass mask evaluation for on-host automation.
|
||||
- Consistent `ProblemDetails` responses and policy helpers shared with Feedser/Backend services.
|
||||
|
||||
Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration.
|
||||
# StellaOps.Auth.ServerIntegration
|
||||
|
||||
ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**:
|
||||
|
||||
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
|
||||
- Network bypass mask evaluation for on-host automation.
|
||||
- Consistent `ProblemDetails` responses and policy helpers shared with Concelier/Backend services.
|
||||
|
||||
Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
@@ -42,23 +45,91 @@ 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);
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Documents.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Documents[document.ClientId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var removed = Documents.Remove(clientId);
|
||||
return ValueTask.FromResult(removed);
|
||||
@@ -69,16 +140,16 @@ public class StandardClientProvisioningStoreTests
|
||||
{
|
||||
public List<AuthorityRevocationDocument> Upserts { get; } = new();
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Upserts.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(true);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +319,13 @@ internal sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
|
||||
internal sealed class StubRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
|
||||
@@ -333,18 +333,18 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients[document.ClientId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(clients.Remove(clientId));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsAuthorityPlugin>true</IsAuthorityPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsAuthorityPlugin>true</IsAuthorityPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,150 +1,235 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityRevocationStore revocationStore;
|
||||
private readonly TimeProvider clock;
|
||||
|
||||
public StandardClientProvisioningStore(
|
||||
string pluginName,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityRevocationStore revocationStore,
|
||||
TimeProvider clock)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
|
||||
AuthorityClientRegistration registration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
|
||||
{
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
|
||||
}
|
||||
|
||||
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
|
||||
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
|
||||
|
||||
document.Plugin = pluginName;
|
||||
document.ClientType = registration.Confidential ? "confidential" : "public";
|
||||
document.DisplayName = registration.DisplayName;
|
||||
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
|
||||
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
|
||||
: null;
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = string.Join(" ", registration.AllowedGrantTypes);
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = string.Join(" ", registration.AllowedScopes);
|
||||
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
|
||||
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
|
||||
|
||||
foreach (var (key, value) in registration.Properties)
|
||||
{
|
||||
document.Properties[key] = value;
|
||||
}
|
||||
|
||||
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : ToDescriptor(document);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plugin"] = pluginName
|
||||
};
|
||||
|
||||
var revocation = new AuthorityRevocationDocument
|
||||
{
|
||||
Category = "client",
|
||||
RevocationId = clientId,
|
||||
ClientId = clientId,
|
||||
Reason = "operator_request",
|
||||
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
|
||||
RevokedAt = now,
|
||||
EffectiveAt = now,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Revocation export should proceed even if the metadata write fails.
|
||||
}
|
||||
|
||||
return AuthorityPluginOperationResult.Success();
|
||||
}
|
||||
|
||||
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
|
||||
{
|
||||
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
|
||||
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
|
||||
|
||||
var redirectUris = document.RedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
var postLogoutUris = document.PostLogoutRedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
return new AuthorityClientDescriptor(
|
||||
document.ClientId,
|
||||
document.DisplayName,
|
||||
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
|
||||
allowedGrantTypes,
|
||||
allowedScopes,
|
||||
redirectUris,
|
||||
postLogoutUris,
|
||||
document.Properties);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
|
||||
{
|
||||
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityRevocationStore revocationStore;
|
||||
private readonly TimeProvider clock;
|
||||
|
||||
public StandardClientProvisioningStore(
|
||||
string pluginName,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityRevocationStore revocationStore,
|
||||
TimeProvider clock)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
|
||||
AuthorityClientRegistration registration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
|
||||
{
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
|
||||
}
|
||||
|
||||
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
|
||||
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
|
||||
|
||||
document.Plugin = pluginName;
|
||||
document.ClientType = registration.Confidential ? "confidential" : "public";
|
||||
document.DisplayName = registration.DisplayName;
|
||||
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
|
||||
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
|
||||
: null;
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
|
||||
{
|
||||
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
|
||||
if (normalizedConstraint is not null)
|
||||
{
|
||||
document.SenderConstraint = normalizedConstraint;
|
||||
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
|
||||
}
|
||||
else
|
||||
{
|
||||
document.SenderConstraint = null;
|
||||
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
|
||||
}
|
||||
}
|
||||
|
||||
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : ToDescriptor(document);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plugin"] = pluginName
|
||||
};
|
||||
|
||||
var revocation = new AuthorityRevocationDocument
|
||||
{
|
||||
Category = "client",
|
||||
RevocationId = clientId,
|
||||
ClientId = clientId,
|
||||
Reason = "operator_request",
|
||||
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
|
||||
RevokedAt = now,
|
||||
EffectiveAt = now,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Revocation export should proceed even if the metadata write fails.
|
||||
}
|
||||
|
||||
return AuthorityPluginOperationResult.Success();
|
||||
}
|
||||
|
||||
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
|
||||
{
|
||||
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
|
||||
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
|
||||
|
||||
var redirectUris = document.RedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
var postLogoutUris = document.PostLogoutRedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.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);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
|
||||
{
|
||||
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim() switch
|
||||
{
|
||||
{ Length: 0 } => null,
|
||||
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
|
||||
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known metadata keys persisted with Authority client registrations.
|
||||
/// </summary>
|
||||
public static class AuthorityClientMetadataKeys
|
||||
{
|
||||
public const string AllowedGrantTypes = "allowedGrantTypes";
|
||||
public const string AllowedScopes = "allowedScopes";
|
||||
public const string RedirectUris = "redirectUris";
|
||||
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
|
||||
}
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known metadata keys persisted with Authority client registrations.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Captures certificate metadata associated with an mTLS-bound client.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityClientCertificateBinding
|
||||
{
|
||||
[BsonElement("thumbprint")]
|
||||
public string Thumbprint { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("serialNumber")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SerialNumber { get; set; }
|
||||
|
||||
[BsonElement("subject")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
[BsonElement("issuer")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
[BsonElement("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; set; }
|
||||
|
||||
[BsonElement("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; set; }
|
||||
|
||||
[BsonElement("subjectAlternativeNames")]
|
||||
public List<string> SubjectAlternativeNames { get; set; } = new();
|
||||
|
||||
[BsonElement("label")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Label { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,61 +1,69 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth client/application registered with Authority.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityClientDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("clientId")]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("clientType")]
|
||||
public string ClientType { get; set; } = "confidential";
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[BsonElement("secretHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SecretHash { get; set; }
|
||||
|
||||
[BsonElement("permissions")]
|
||||
public List<string> Permissions { get; set; } = new();
|
||||
|
||||
[BsonElement("requirements")]
|
||||
public List<string> Requirements { get; set; } = new();
|
||||
|
||||
[BsonElement("redirectUris")]
|
||||
public List<string> RedirectUris { get; set; } = new();
|
||||
|
||||
[BsonElement("postLogoutRedirectUris")]
|
||||
public List<string> PostLogoutRedirectUris { get; set; } = new();
|
||||
|
||||
[BsonElement("properties")]
|
||||
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("plugin")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Plugin { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("disabled")]
|
||||
public bool Disabled { get; set; }
|
||||
}
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth client/application registered with Authority.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityClientDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("clientId")]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("clientType")]
|
||||
public string ClientType { get; set; } = "confidential";
|
||||
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[BsonElement("secretHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SecretHash { get; set; }
|
||||
|
||||
[BsonElement("permissions")]
|
||||
public List<string> Permissions { get; set; } = new();
|
||||
|
||||
[BsonElement("requirements")]
|
||||
public List<string> Requirements { get; set; } = new();
|
||||
|
||||
[BsonElement("redirectUris")]
|
||||
public List<string> RedirectUris { get; set; } = new();
|
||||
|
||||
[BsonElement("postLogoutRedirectUris")]
|
||||
public List<string> PostLogoutRedirectUris { get; set; } = new();
|
||||
|
||||
[BsonElement("properties")]
|
||||
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[BsonElement("plugin")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Plugin { get; set; }
|
||||
|
||||
[BsonElement("senderConstraint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SenderConstraint { get; set; }
|
||||
|
||||
[BsonElement("certificateBindings")]
|
||||
public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("disabled")]
|
||||
public bool Disabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,126 +1,129 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
using StellaOps.Authority.Storage.Mongo.Options;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAuthorityMongoStorage(
|
||||
this IServiceCollection services,
|
||||
Action<AuthorityMongoOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.AddOptions<AuthorityMongoOptions>()
|
||||
.Configure(configureOptions)
|
||||
.PostConfigure(static options => options.EnsureValid());
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
services.AddSingleton<IMongoClient>(static sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
|
||||
return new MongoClient(options.ConnectionString);
|
||||
});
|
||||
|
||||
services.AddSingleton<IMongoDatabase>(static sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
|
||||
var client = sp.GetRequiredService<IMongoClient>();
|
||||
|
||||
var settings = new MongoDatabaseSettings
|
||||
{
|
||||
ReadConcern = ReadConcern.Majority,
|
||||
WriteConcern = WriteConcern.WMajority,
|
||||
ReadPreference = ReadPreference.PrimaryPreferred
|
||||
};
|
||||
|
||||
var database = client.GetDatabase(options.GetDatabaseName(), settings);
|
||||
var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
|
||||
return database.WithWriteConcern(writeConcern);
|
||||
});
|
||||
|
||||
services.AddSingleton<AuthorityMongoInitializer>();
|
||||
services.AddSingleton<AuthorityMongoMigrationRunner>();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
|
||||
|
||||
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
|
||||
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
|
||||
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
|
||||
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
|
||||
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
|
||||
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Migrations;
|
||||
using StellaOps.Authority.Storage.Mongo.Options;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAuthorityMongoStorage(
|
||||
this IServiceCollection services,
|
||||
Action<AuthorityMongoOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.AddOptions<AuthorityMongoOptions>()
|
||||
.Configure(configureOptions)
|
||||
.PostConfigure(static options => options.EnsureValid());
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
services.AddSingleton<IMongoClient>(static sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
|
||||
return new MongoClient(options.ConnectionString);
|
||||
});
|
||||
|
||||
services.AddSingleton<IMongoDatabase>(static sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
|
||||
var client = sp.GetRequiredService<IMongoClient>();
|
||||
|
||||
var settings = new MongoDatabaseSettings
|
||||
{
|
||||
ReadConcern = ReadConcern.Majority,
|
||||
WriteConcern = WriteConcern.WMajority,
|
||||
ReadPreference = ReadPreference.PrimaryPreferred
|
||||
};
|
||||
|
||||
var database = client.GetDatabase(options.GetDatabaseName(), settings);
|
||||
var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
|
||||
return database.WithWriteConcern(writeConcern);
|
||||
});
|
||||
|
||||
services.AddSingleton<AuthorityMongoInitializer>();
|
||||
services.AddSingleton<AuthorityMongoMigrationRunner>();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
|
||||
|
||||
services.AddScoped<IAuthorityMongoSessionAccessor, AuthorityMongoSessionAccessor>();
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
|
||||
|
||||
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
|
||||
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
|
||||
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
|
||||
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
|
||||
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
|
||||
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
|
||||
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
|
||||
new CreateIndexOptions { Name = "client_disabled" })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
|
||||
|
||||
var indexModels = new[]
|
||||
{
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
|
||||
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
|
||||
new CreateIndexOptions { Name = "client_disabled" }),
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.SenderConstraint),
|
||||
new CreateIndexOptions { Name = "client_sender_constraint" }),
|
||||
new CreateIndexModel<AuthorityClientDocument>(
|
||||
Builders<AuthorityClientDocument>.IndexKeys.Ascending("certificateBindings.thumbprint"),
|
||||
new CreateIndexOptions { Name = "client_cert_thumbprints" })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,51 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
|
||||
var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
|
||||
{
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys
|
||||
.Ascending(t => t.Status)
|
||||
.Ascending(t => t.RevokedAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" })
|
||||
};
|
||||
|
||||
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
|
||||
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Name = "token_expiry_ttl",
|
||||
ExpireAfter = TimeSpan.Zero,
|
||||
PartialFilterExpression = expirationFilter
|
||||
}));
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
|
||||
var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
|
||||
{
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys
|
||||
.Ascending(t => t.Status)
|
||||
.Ascending(t => t.RevokedAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" }),
|
||||
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);
|
||||
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Name = "token_expiry_ttl",
|
||||
ExpireAfter = TimeSpan.Zero,
|
||||
PartialFilterExpression = expirationFilter
|
||||
}));
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Options;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
|
||||
public interface IAuthorityMongoSessionAccessor : IAsyncDisposable
|
||||
{
|
||||
ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class AuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||
{
|
||||
private readonly IMongoClient client;
|
||||
private readonly AuthorityMongoOptions options;
|
||||
private readonly object gate = new();
|
||||
private Task<IClientSessionHandle>? sessionTask;
|
||||
private IClientSessionHandle? session;
|
||||
private bool disposed;
|
||||
|
||||
public AuthorityMongoSessionAccessor(
|
||||
IMongoClient client,
|
||||
IOptions<AuthorityMongoOptions> options)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public async ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
var existing = Volatile.Read(ref session);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
Task<IClientSessionHandle> startTask;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (session is { } cached)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
sessionTask ??= StartSessionInternalAsync(cancellationToken);
|
||||
startTask = sessionTask;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var handle = await startTask.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
session = handle;
|
||||
sessionTask = Task.FromResult(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (ReferenceEquals(sessionTask, startTask))
|
||||
{
|
||||
sessionTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IClientSessionHandle> StartSessionInternalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionOptions = new ClientSessionOptions
|
||||
{
|
||||
CausalConsistency = true,
|
||||
DefaultTransactionOptions = new TransactionOptions(
|
||||
readPreference: ReadPreference.Primary,
|
||||
readConcern: ReadConcern.Majority,
|
||||
writeConcern: WriteConcern.WMajority.With(wTimeout: options.CommandTimeout))
|
||||
};
|
||||
|
||||
var handle = await client.StartSessionAsync(sessionOptions, cancellationToken).ConfigureAwait(false);
|
||||
return handle;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
IClientSessionHandle? handle;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
handle = session;
|
||||
session = null;
|
||||
sessionTask = null;
|
||||
}
|
||||
|
||||
if (handle is not null)
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
@@ -12,11 +14,19 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
|
||||
public AuthorityBootstrapInviteStore(IMongoCollection<AuthorityBootstrapInviteDocument> collection)
|
||||
=> this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
|
||||
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (session is { })
|
||||
{
|
||||
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
@@ -25,7 +35,8 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
|
||||
string expectedType,
|
||||
DateTimeOffset now,
|
||||
string? reservedBy,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
@@ -33,8 +44,9 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
|
||||
}
|
||||
|
||||
var normalizedToken = token.Trim();
|
||||
var tokenFilter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken);
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken),
|
||||
tokenFilter,
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Pending));
|
||||
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
@@ -47,14 +59,31 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
var invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
AuthorityBootstrapInviteDocument? invite;
|
||||
if (session is { })
|
||||
{
|
||||
invite = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (invite is null)
|
||||
{
|
||||
var existing = await collection
|
||||
.Find(i => i.Token == normalizedToken)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AuthorityBootstrapInviteDocument? existing;
|
||||
if (session is { })
|
||||
{
|
||||
existing = await collection.Find(session, tokenFilter)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing = await collection.Find(tokenFilter)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
@@ -76,60 +105,76 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
|
||||
|
||||
if (!string.Equals(invite.Type, expectedType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ReleaseAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
|
||||
await ReleaseAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite);
|
||||
}
|
||||
|
||||
if (invite.ExpiresAt <= now)
|
||||
{
|
||||
await MarkExpiredAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
|
||||
await MarkExpiredAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite);
|
||||
}
|
||||
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
|
||||
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await collection.UpdateOneAsync(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
|
||||
.Set(i => i.ReservedAt, null)
|
||||
.Set(i => i.ReservedBy, null),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
|
||||
.Set(i => i.ReservedAt, null)
|
||||
.Set(i => i.ReservedBy, null);
|
||||
|
||||
UpdateResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
|
||||
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await collection.UpdateOneAsync(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
|
||||
.Set(i => i.ConsumedAt, consumedAt)
|
||||
.Set(i => i.ConsumedBy, consumedBy),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
|
||||
.Set(i => i.ConsumedAt, consumedAt)
|
||||
.Set(i => i.ConsumedBy, consumedBy);
|
||||
|
||||
UpdateResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Lte(i => i.ExpiresAt, now),
|
||||
@@ -142,25 +187,49 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
|
||||
.Set(i => i.ReservedAt, null)
|
||||
.Set(i => i.ReservedBy, null);
|
||||
|
||||
var expired = await collection.Find(filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
List<AuthorityBootstrapInviteDocument> expired;
|
||||
if (session is { })
|
||||
{
|
||||
expired = await collection.Find(session, filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
expired = await collection.Find(filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return Array.Empty<AuthorityBootstrapInviteDocument>();
|
||||
}
|
||||
|
||||
await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (session is { })
|
||||
{
|
||||
await collection.UpdateManyAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken)
|
||||
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session)
|
||||
{
|
||||
await collection.UpdateOneAsync(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token);
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired);
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,88 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityClientDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityClientStore> logger;
|
||||
|
||||
public AuthorityClientStore(
|
||||
IMongoCollection<AuthorityClientDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityClientStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = clientId.Trim();
|
||||
return await collection.Find(c => c.ClientId == id)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority client {ClientId}.", document.ClientId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var id = clientId.Trim();
|
||||
var result = await collection.DeleteOneAsync(c => c.ClientId == id, cancellationToken).ConfigureAwait(false);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityClientDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityClientStore> logger;
|
||||
|
||||
public AuthorityClientStore(
|
||||
IMongoCollection<AuthorityClientDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityClientStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = clientId.Trim();
|
||||
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
|
||||
var cursor = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority client {ClientId}.", document.ClientId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var id = clientId.Trim();
|
||||
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,72 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection;
|
||||
private readonly ILogger<AuthorityLoginAttemptStore> logger;
|
||||
|
||||
public AuthorityLoginAttemptStore(
|
||||
IMongoCollection<AuthorityLoginAttemptDocument> collection,
|
||||
ILogger<AuthorityLoginAttemptStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug(
|
||||
"Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
|
||||
document.EventType,
|
||||
document.SubjectId ?? document.Username ?? "<unknown>",
|
||||
document.Outcome);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
|
||||
{
|
||||
return Array.Empty<AuthorityLoginAttemptDocument>();
|
||||
}
|
||||
|
||||
var normalized = subjectId.Trim();
|
||||
|
||||
var cursor = await collection.FindAsync(
|
||||
Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized),
|
||||
new FindOptions<AuthorityLoginAttemptDocument>
|
||||
{
|
||||
Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
|
||||
Limit = limit
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection;
|
||||
private readonly ILogger<AuthorityLoginAttemptStore> logger;
|
||||
|
||||
public AuthorityLoginAttemptStore(
|
||||
IMongoCollection<AuthorityLoginAttemptDocument> collection,
|
||||
ILogger<AuthorityLoginAttemptStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (session is { })
|
||||
{
|
||||
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
|
||||
document.EventType,
|
||||
document.SubjectId ?? document.Username ?? "<unknown>",
|
||||
document.Outcome);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
|
||||
{
|
||||
return Array.Empty<AuthorityLoginAttemptDocument>();
|
||||
}
|
||||
|
||||
var normalized = subjectId.Trim();
|
||||
|
||||
var filter = Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized);
|
||||
var options = new FindOptions<AuthorityLoginAttemptDocument>
|
||||
{
|
||||
Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
|
||||
Limit = limit
|
||||
};
|
||||
|
||||
IAsyncCursor<AuthorityLoginAttemptDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,97 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore
|
||||
{
|
||||
private const string StateId = "state";
|
||||
|
||||
private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection;
|
||||
private readonly ILogger<AuthorityRevocationExportStateStore> logger;
|
||||
|
||||
public AuthorityRevocationExportStateStore(
|
||||
IMongoCollection<AuthorityRevocationExportStateDocument> collection,
|
||||
ILogger<AuthorityRevocationExportStateStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
|
||||
return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (newSequence <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive.");
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
|
||||
|
||||
if (expectedSequence > 0)
|
||||
{
|
||||
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence);
|
||||
}
|
||||
else
|
||||
{
|
||||
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or(
|
||||
Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false),
|
||||
Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0));
|
||||
}
|
||||
|
||||
var update = Builders<AuthorityRevocationExportStateDocument>.Update
|
||||
.Set(d => d.Sequence, newSequence)
|
||||
.Set(d => d.LastBundleId, bundleId)
|
||||
.Set(d => d.LastIssuedAt, issuedAt);
|
||||
|
||||
var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument>
|
||||
{
|
||||
IsUpsert = expectedSequence == 0,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("Revocation export state update conflict.");
|
||||
}
|
||||
|
||||
logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence);
|
||||
return result;
|
||||
}
|
||||
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore
|
||||
{
|
||||
private const string StateId = "state";
|
||||
|
||||
private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection;
|
||||
private readonly ILogger<AuthorityRevocationExportStateStore> logger;
|
||||
|
||||
public AuthorityRevocationExportStateStore(
|
||||
IMongoCollection<AuthorityRevocationExportStateDocument> collection,
|
||||
ILogger<AuthorityRevocationExportStateStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (newSequence <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive.");
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
|
||||
|
||||
if (expectedSequence > 0)
|
||||
{
|
||||
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence);
|
||||
}
|
||||
else
|
||||
{
|
||||
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or(
|
||||
Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false),
|
||||
Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0));
|
||||
}
|
||||
|
||||
var update = Builders<AuthorityRevocationExportStateDocument>.Update
|
||||
.Set(d => d.Sequence, newSequence)
|
||||
.Set(d => d.LastBundleId, bundleId)
|
||||
.Set(d => d.LastIssuedAt, issuedAt);
|
||||
|
||||
var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument>
|
||||
{
|
||||
IsUpsert = expectedSequence == 0,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
AuthorityRevocationExportStateDocument? result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("Revocation export state update conflict.");
|
||||
}
|
||||
|
||||
logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence);
|
||||
return result;
|
||||
}
|
||||
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,143 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityRevocationDocument> collection;
|
||||
private readonly ILogger<AuthorityRevocationStore> logger;
|
||||
|
||||
public AuthorityRevocationStore(
|
||||
IMongoCollection<AuthorityRevocationDocument> collection,
|
||||
ILogger<AuthorityRevocationStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(document.Category))
|
||||
{
|
||||
throw new ArgumentException("Revocation category is required.", nameof(document));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(document.RevocationId))
|
||||
{
|
||||
throw new ArgumentException("Revocation identifier is required.", nameof(document));
|
||||
}
|
||||
|
||||
document.Category = document.Category.Trim();
|
||||
document.RevocationId = document.RevocationId.Trim();
|
||||
document.Scopes = NormalizeScopes(document.Scopes);
|
||||
document.Metadata = NormalizeMetadata(document.Metadata);
|
||||
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
document.UpdatedAt = now;
|
||||
|
||||
var existing = await collection
|
||||
.Find(filter)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
document.CreatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
document.Id = existing.Id;
|
||||
document.CreatedAt = existing.CreatedAt;
|
||||
}
|
||||
|
||||
await collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
|
||||
|
||||
var result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
if (result.DeletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
|
||||
|
||||
var documents = await collection
|
||||
.Find(filter)
|
||||
.Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static List<string>? NormalizeScopes(List<string>? scopes)
|
||||
{
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distinct = scopes
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return distinct.Count == 0 ? null : distinct;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[pair.Key.Trim()] = pair.Value;
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityRevocationDocument> collection;
|
||||
private readonly ILogger<AuthorityRevocationStore> logger;
|
||||
|
||||
public AuthorityRevocationStore(
|
||||
IMongoCollection<AuthorityRevocationDocument> collection,
|
||||
ILogger<AuthorityRevocationStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(document.Category))
|
||||
{
|
||||
throw new ArgumentException("Revocation category is required.", nameof(document));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(document.RevocationId))
|
||||
{
|
||||
throw new ArgumentException("Revocation identifier is required.", nameof(document));
|
||||
}
|
||||
|
||||
document.Category = document.Category.Trim();
|
||||
document.RevocationId = document.RevocationId.Trim();
|
||||
document.Scopes = NormalizeScopes(document.Scopes);
|
||||
document.Metadata = NormalizeMetadata(document.Metadata);
|
||||
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
document.UpdatedAt = now;
|
||||
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
var existing = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
document.CreatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
document.Id = existing.Id;
|
||||
document.CreatedAt = existing.CreatedAt;
|
||||
}
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
if (session is { })
|
||||
{
|
||||
await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (result.DeletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
|
||||
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
var documents = await query
|
||||
.Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static List<string>? NormalizeScopes(List<string>? scopes)
|
||||
{
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distinct = scopes
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return distinct.Count == 0 ? null : distinct;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[pair.Key.Trim()] = pair.Value;
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,104 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityScopeStore : IAuthorityScopeStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityScopeDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityScopeStore> logger;
|
||||
|
||||
public AuthorityScopeStore(
|
||||
IMongoCollection<AuthorityScopeDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityScopeStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
return await collection.Find(s => s.Name == normalized)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
var result = await collection.DeleteOneAsync(s => s.Name == normalized, cancellationToken).ConfigureAwait(false);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityScopeStore : IAuthorityScopeStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityScopeDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityScopeStore> logger;
|
||||
|
||||
public AuthorityScopeStore(
|
||||
IMongoCollection<AuthorityScopeDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityScopeStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
IAsyncCursor<AuthorityScopeDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
@@ -22,15 +24,23 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
|
||||
public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (session is { })
|
||||
{
|
||||
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogDebug("Inserted Authority token {TokenId}.", document.TokenId);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken)
|
||||
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
@@ -38,12 +48,15 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
}
|
||||
|
||||
var id = tokenId.Trim();
|
||||
return await collection.Find(t => t.TokenId == id)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
|
||||
public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(referenceId))
|
||||
{
|
||||
@@ -51,9 +64,12 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
}
|
||||
|
||||
var id = referenceId.Trim();
|
||||
return await collection.Find(t => t.ReferenceId == id)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ReferenceId, id);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpdateStatusAsync(
|
||||
@@ -63,7 +79,8 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
string? reason,
|
||||
string? reasonDescription,
|
||||
IReadOnlyDictionary<string, string?>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
@@ -82,16 +99,29 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
.Set(t => t.RevokedReasonDescription, reasonDescription)
|
||||
.Set(t => t.RevokedMetadata, metadata is null ? null : new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var result = await collection.UpdateOneAsync(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()),
|
||||
update,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim());
|
||||
|
||||
UpdateResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogDebug("Updated token {TokenId} status to {Status} (matched {Matched}).", tokenId, status, result.MatchedCount);
|
||||
}
|
||||
|
||||
|
||||
public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
|
||||
public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(
|
||||
string tokenId,
|
||||
string? remoteAddress,
|
||||
string? userAgent,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
@@ -104,10 +134,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
}
|
||||
|
||||
var id = tokenId.Trim();
|
||||
var token = await collection
|
||||
.Find(t => t.TokenId == id)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
var token = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
@@ -147,10 +178,14 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
}
|
||||
|
||||
var update = Builders<AuthorityTokenDocument>.Update.Set(t => t.Devices, token.Devices);
|
||||
await collection.UpdateOneAsync(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id),
|
||||
update,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (session is { })
|
||||
{
|
||||
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
|
||||
}
|
||||
@@ -170,14 +205,22 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
|
||||
public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(
|
||||
Builders<AuthorityTokenDocument>.Filter.Not(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked")),
|
||||
Builders<AuthorityTokenDocument>.Filter.Lt(t => t.ExpiresAt, threshold));
|
||||
|
||||
var result = await collection.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteManyAsync(session, filter, options: null, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteManyAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (result.DeletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Deleted {Count} expired Authority tokens.", result.DeletedCount);
|
||||
@@ -186,7 +229,7 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
return result.DeletedCount;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
|
||||
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked");
|
||||
|
||||
@@ -197,8 +240,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.RevokedAt, threshold));
|
||||
}
|
||||
|
||||
var documents = await collection
|
||||
.Find(filter)
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
var documents = await query
|
||||
.Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -1,81 +1,105 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityUserStore : IAuthorityUserStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityUserDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityUserStore> logger;
|
||||
|
||||
public AuthorityUserStore(
|
||||
IMongoCollection<AuthorityUserDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityUserStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await collection
|
||||
.Find(u => u.SubjectId == subjectId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedUsername))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalised = normalizedUsername.Trim();
|
||||
|
||||
return await collection
|
||||
.Find(u => u.NormalizedUsername == normalised)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
var result = await collection
|
||||
.ReplaceOneAsync(filter, document, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority user {SubjectId}.", document.SubjectId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalised = subjectId.Trim();
|
||||
var result = await collection.DeleteOneAsync(u => u.SubjectId == normalised, cancellationToken).ConfigureAwait(false);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityUserStore : IAuthorityUserStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityUserDocument> collection;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<AuthorityUserStore> logger;
|
||||
|
||||
public AuthorityUserStore(
|
||||
IMongoCollection<AuthorityUserDocument> collection,
|
||||
TimeProvider clock,
|
||||
ILogger<AuthorityUserStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = subjectId.Trim();
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalized);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedUsername))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalised = normalizedUsername.Trim();
|
||||
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.NormalizedUsername, normalised);
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
ReplaceOneResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.UpsertedId is not null)
|
||||
{
|
||||
logger.LogInformation("Inserted Authority user {SubjectId}.", document.SubjectId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalised = subjectId.Trim();
|
||||
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalised);
|
||||
|
||||
DeleteResult result;
|
||||
if (session is { })
|
||||
{
|
||||
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityBootstrapInviteStore
|
||||
{
|
||||
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken);
|
||||
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken);
|
||||
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken);
|
||||
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken);
|
||||
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken);
|
||||
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
public enum BootstrapInviteReservationStatus
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityClientStore
|
||||
{
|
||||
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken);
|
||||
}
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityClientStore
|
||||
{
|
||||
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityLoginAttemptStore
|
||||
{
|
||||
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken);
|
||||
}
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityLoginAttemptStore
|
||||
{
|
||||
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationExportStateStore
|
||||
{
|
||||
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationExportStateStore
|
||||
{
|
||||
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationStore
|
||||
{
|
||||
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationStore
|
||||
{
|
||||
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityScopeStore
|
||||
{
|
||||
ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken);
|
||||
}
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityScopeStore
|
||||
{
|
||||
ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityTokenStore
|
||||
{
|
||||
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken);
|
||||
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken);
|
||||
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken);
|
||||
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpdateStatusAsync(
|
||||
string tokenId,
|
||||
@@ -19,13 +20,14 @@ public interface IAuthorityTokenStore
|
||||
string? reason,
|
||||
string? reasonDescription,
|
||||
IReadOnlyDictionary<string, string?>? metadata,
|
||||
CancellationToken cancellationToken);
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
|
||||
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken);
|
||||
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
public enum TokenUsageUpdateStatus
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityUserStore
|
||||
{
|
||||
ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
|
||||
}
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityUserStore
|
||||
{
|
||||
ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
@@ -65,19 +66,19 @@ public sealed class BootstrapInviteCleanupServiceTests
|
||||
|
||||
public bool ExpireCalled { get; private set; }
|
||||
|
||||
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
|
||||
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken)
|
||||
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
|
||||
|
||||
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
|
||||
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
|
||||
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ExpireCalled = true;
|
||||
return ValueTask.FromResult(invites);
|
||||
|
||||
@@ -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;
|
||||
@@ -13,11 +25,13 @@ using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.OpenIddict.Handlers;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using static StellaOps.Authority.Tests.OpenIddict.TestHelpers;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
@@ -42,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");
|
||||
@@ -70,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");
|
||||
@@ -102,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");
|
||||
@@ -115,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()
|
||||
{
|
||||
@@ -122,11 +451,13 @@ 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);
|
||||
var tokenStore = new TestTokenStore();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
@@ -136,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");
|
||||
@@ -148,10 +481,11 @@ public class ClientCredentialsHandlersTests
|
||||
var handler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
|
||||
@@ -159,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);
|
||||
|
||||
@@ -197,13 +532,15 @@ public class TokenValidationHandlersTests
|
||||
{
|
||||
TokenId = "token-1",
|
||||
Status = "revoked",
|
||||
ClientId = "feedser"
|
||||
ClientId = "concelier"
|
||||
};
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
new TestClientStore(CreateClient()),
|
||||
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())),
|
||||
metadataAccessor,
|
||||
@@ -219,7 +556,7 @@ public class TokenValidationHandlersTests
|
||||
Request = new OpenIddictRequest()
|
||||
};
|
||||
|
||||
var principal = CreatePrincipal("feedser", "token-1", "standard");
|
||||
var principal = CreatePrincipal("concelier", "token-1", "standard");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
@@ -248,8 +585,10 @@ public class TokenValidationHandlersTests
|
||||
|
||||
var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor();
|
||||
var auditSinkSuccess = new TestAuthEventSink();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
new TestTokenStore(),
|
||||
sessionAccessor,
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
metadataAccessorSuccess,
|
||||
@@ -277,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()
|
||||
{
|
||||
@@ -313,8 +708,10 @@ public class TokenValidationHandlersTests
|
||||
clientDocument.ClientId = "agent";
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
|
||||
var sessionAccessorReplay = new NullMongoSessionAccessor();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessorReplay,
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
metadataAccessor,
|
||||
@@ -348,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);
|
||||
@@ -360,19 +840,19 @@ internal sealed class TestClientStore : IAuthorityClientStore
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients[document.ClientId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(clients.Remove(clientId));
|
||||
}
|
||||
|
||||
@@ -382,28 +862,28 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
|
||||
|
||||
public Func<string?, string?, TokenUsageUpdateResult>? UsageCallback { get; set; }
|
||||
|
||||
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
|
||||
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Inserted = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken)
|
||||
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null);
|
||||
|
||||
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
|
||||
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<AuthorityTokenDocument?>(null);
|
||||
|
||||
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken)
|
||||
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
|
||||
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(0L);
|
||||
|
||||
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
|
||||
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent));
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
|
||||
}
|
||||
|
||||
@@ -516,17 +996,39 @@ 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)
|
||||
=> ValueTask.FromResult<IClientSessionHandle>(null!);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
public static AuthorityClientDocument CreateClient(
|
||||
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 = "feedser",
|
||||
ClientId = "concelier",
|
||||
ClientType = clientType,
|
||||
SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret),
|
||||
Plugin = "standard",
|
||||
@@ -536,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,
|
||||
@@ -549,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);
|
||||
@@ -620,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;
|
||||
@@ -16,9 +17,11 @@ using StellaOps.Authority.Storage.Mongo;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Feedser.Testing;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
@@ -59,9 +62,11 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
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);
|
||||
|
||||
var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
|
||||
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15);
|
||||
@@ -151,8 +156,11 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
clientStore,
|
||||
registry,
|
||||
metadataAccessor,
|
||||
@@ -249,6 +257,107 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MongoSessions_ProvideReadYourWriteAfterPrimaryElection()
|
||||
{
|
||||
await ResetCollectionsAsync();
|
||||
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var provider = await BuildMongoProviderAsync(clock);
|
||||
|
||||
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
|
||||
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
var session = await sessionAccessor.GetSessionAsync(CancellationToken.None);
|
||||
|
||||
var tokenId = $"election-token-{Guid.NewGuid():N}";
|
||||
var document = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = tokenId,
|
||||
Type = OpenIddictConstants.TokenTypeHints.AccessToken,
|
||||
SubjectId = "session-subject",
|
||||
ClientId = "session-client",
|
||||
Scope = new List<string> { "jobs:read" },
|
||||
Status = "valid",
|
||||
CreatedAt = clock.GetUtcNow(),
|
||||
ExpiresAt = clock.GetUtcNow().AddMinutes(30)
|
||||
};
|
||||
|
||||
await tokenStore.InsertAsync(document, CancellationToken.None, session);
|
||||
|
||||
await StepDownPrimaryAsync(fixture.Client, CancellationToken.None);
|
||||
|
||||
AuthorityTokenDocument? fetched = null;
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
fetched = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session);
|
||||
if (fetched is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (MongoException)
|
||||
{
|
||||
await Task.Delay(250);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(tokenId, fetched!.TokenId);
|
||||
}
|
||||
|
||||
private static async Task StepDownPrimaryAsync(IMongoClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
var admin = client.GetDatabase("admin");
|
||||
try
|
||||
{
|
||||
var command = new BsonDocument
|
||||
{
|
||||
{ "replSetStepDown", 5 },
|
||||
{ "force", true }
|
||||
};
|
||||
|
||||
await admin.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (MongoCommandException)
|
||||
{
|
||||
// Expected when the current primary steps down.
|
||||
}
|
||||
catch (MongoConnectionException)
|
||||
{
|
||||
// Connection may drop during election; ignore and continue.
|
||||
}
|
||||
|
||||
await WaitForPrimaryAsync(admin, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task WaitForPrimaryAsync(IMongoDatabase adminDatabase, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; attempt < 40; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var status = await adminDatabase.RunCommandAsync<BsonDocument>(new BsonDocument { { "replSetGetStatus", 1 } }, cancellationToken: cancellationToken);
|
||||
if (status.TryGetValue("myState", out var state) && state.ToInt32() == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (MongoCommandException)
|
||||
{
|
||||
// Ignore intermediate states and retry.
|
||||
}
|
||||
|
||||
await Task.Delay(250, cancellationToken);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Replica set primary election did not complete in time.");
|
||||
}
|
||||
|
||||
private async Task ResetCollectionsAsync()
|
||||
{
|
||||
var tokens = fixture.Database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
|
||||
@@ -1,141 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.RateLimiting;
|
||||
|
||||
public class AuthorityRateLimiterIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_Returns429_WhenLimitExceeded()
|
||||
{
|
||||
using var server = CreateServer(options =>
|
||||
{
|
||||
options.Security.RateLimiting.Token.PermitLimit = 1;
|
||||
options.Security.RateLimiting.Token.QueueLimit = 0;
|
||||
options.Security.RateLimiting.Token.Window = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
using var client = server.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.50");
|
||||
|
||||
var firstResponse = await client.PostAsync("/token", CreateTokenForm("feedser"));
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
|
||||
var secondResponse = await client.PostAsync("/token", CreateTokenForm("feedser"));
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
||||
Assert.NotNull(secondResponse.Headers.RetryAfter);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_AllowsDifferentClientIdsWithinWindow()
|
||||
{
|
||||
using var server = CreateServer(options =>
|
||||
{
|
||||
options.Security.RateLimiting.Token.PermitLimit = 1;
|
||||
options.Security.RateLimiting.Token.QueueLimit = 0;
|
||||
options.Security.RateLimiting.Token.Window = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
using var client = server.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.70");
|
||||
|
||||
var firstResponse = await client.PostAsync("/token", CreateTokenForm("alpha-client"));
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
|
||||
var secondResponse = await client.PostAsync("/token", CreateTokenForm("beta-client"));
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InternalEndpoint_Returns429_WhenLimitExceeded()
|
||||
{
|
||||
using var server = CreateServer(options =>
|
||||
{
|
||||
options.Security.RateLimiting.Internal.Enabled = true;
|
||||
options.Security.RateLimiting.Internal.PermitLimit = 1;
|
||||
options.Security.RateLimiting.Internal.QueueLimit = 0;
|
||||
options.Security.RateLimiting.Internal.Window = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
using var client = server.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.60");
|
||||
|
||||
var firstResponse = await client.GetAsync("/internal/ping");
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
|
||||
var secondResponse = await client.GetAsync("/internal/ping");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
||||
}
|
||||
|
||||
private static TestServer CreateServer(Action<StellaOpsAuthorityOptions>? configure)
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.integration.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost/authority";
|
||||
|
||||
configure?.Invoke(options);
|
||||
|
||||
var builder = new WebHostBuilder()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
|
||||
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
services.AddHttpContextAccessor();
|
||||
services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
|
||||
services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
|
||||
services.AddRateLimiter(rateLimiterOptions =>
|
||||
{
|
||||
AuthorityRateLimiter.Configure(rateLimiterOptions, options);
|
||||
});
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseAuthorityRateLimiterContext();
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.Map("/token", builder =>
|
||||
{
|
||||
builder.Run(async context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
await context.Response.WriteAsync("token-ok");
|
||||
});
|
||||
});
|
||||
|
||||
app.Map("/internal/ping", builder =>
|
||||
{
|
||||
builder.Run(async context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
await context.Response.WriteAsync("internal-ok");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return new TestServer(builder);
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent CreateTokenForm(string clientId)
|
||||
=> new(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = clientId
|
||||
});
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.RateLimiting;
|
||||
|
||||
public class AuthorityRateLimiterIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_Returns429_WhenLimitExceeded()
|
||||
{
|
||||
using var server = CreateServer(options =>
|
||||
{
|
||||
options.Security.RateLimiting.Token.PermitLimit = 1;
|
||||
options.Security.RateLimiting.Token.QueueLimit = 0;
|
||||
options.Security.RateLimiting.Token.Window = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
using var client = server.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.50");
|
||||
|
||||
var firstResponse = await client.PostAsync("/token", CreateTokenForm("concelier"));
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
|
||||
var secondResponse = await client.PostAsync("/token", CreateTokenForm("concelier"));
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
||||
Assert.NotNull(secondResponse.Headers.RetryAfter);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_AllowsDifferentClientIdsWithinWindow()
|
||||
{
|
||||
using var server = CreateServer(options =>
|
||||
{
|
||||
options.Security.RateLimiting.Token.PermitLimit = 1;
|
||||
options.Security.RateLimiting.Token.QueueLimit = 0;
|
||||
options.Security.RateLimiting.Token.Window = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
using var client = server.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.70");
|
||||
|
||||
var firstResponse = await client.PostAsync("/token", CreateTokenForm("alpha-client"));
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
|
||||
var secondResponse = await client.PostAsync("/token", CreateTokenForm("beta-client"));
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InternalEndpoint_Returns429_WhenLimitExceeded()
|
||||
{
|
||||
using var server = CreateServer(options =>
|
||||
{
|
||||
options.Security.RateLimiting.Internal.Enabled = true;
|
||||
options.Security.RateLimiting.Internal.PermitLimit = 1;
|
||||
options.Security.RateLimiting.Internal.QueueLimit = 0;
|
||||
options.Security.RateLimiting.Internal.Window = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
using var client = server.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.60");
|
||||
|
||||
var firstResponse = await client.GetAsync("/internal/ping");
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
|
||||
var secondResponse = await client.GetAsync("/internal/ping");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
||||
}
|
||||
|
||||
private static TestServer CreateServer(Action<StellaOpsAuthorityOptions>? configure)
|
||||
{
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.integration.test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
options.Storage.ConnectionString = "mongodb://localhost/authority";
|
||||
|
||||
configure?.Invoke(options);
|
||||
|
||||
var builder = new WebHostBuilder()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
|
||||
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
services.AddHttpContextAccessor();
|
||||
services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
|
||||
services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
|
||||
services.AddRateLimiter(rateLimiterOptions =>
|
||||
{
|
||||
AuthorityRateLimiter.Configure(rateLimiterOptions, options);
|
||||
});
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseAuthorityRateLimiterContext();
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.Map("/token", builder =>
|
||||
{
|
||||
builder.Run(async context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
await context.Response.WriteAsync("token-ok");
|
||||
});
|
||||
});
|
||||
|
||||
app.Map("/internal/ping", builder =>
|
||||
{
|
||||
builder.Run(async context =>
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
await context.Response.WriteAsync("internal-ok");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return new TestServer(builder);
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent CreateTokenForm(string clientId)
|
||||
=> new(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = clientId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -25,17 +25,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions.Tests", "StellaOps.Authority.Plugins.Abstractions.Tests\StellaOps.Authority.Plugins.Abstractions.Tests.csproj", "{EE97137B-22AF-4A84-9F65-9B4C6468B3CF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Testing", "..\StellaOps.Feedser.Testing\StellaOps.Feedser.Testing.csproj", "{D48E48BF-80C8-43DA-8BE6-E2B9E769C49E}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing", "..\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj", "{D48E48BF-80C8-43DA-8BE6-E2B9E769C49E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Common", "..\StellaOps.Feedser.Source.Common\StellaOps.Feedser.Source.Common.csproj", "{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Storage.Mongo", "..\StellaOps.Feedser.Storage.Mongo\StellaOps.Feedser.Storage.Mongo.csproj", "{67C85AC6-1670-4A0D-A81F-6015574F46C7}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{67C85AC6-1670-4A0D-A81F-6015574F46C7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "..\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{17829125-C0F5-47E6-A16C-EC142BD58220}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{17829125-C0F5-47E6-A16C-EC142BD58220}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Models", "..\StellaOps.Feedser.Models\StellaOps.Feedser.Models.csproj", "{9B4BA030-C979-4191-8B4F-7E2AD9F88A94}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{9B4BA030-C979-4191-8B4F-7E2AD9F88A94}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Normalization", "..\StellaOps.Feedser.Normalization\StellaOps.Feedser.Normalization.csproj", "{26B58A9B-DB0B-4E3D-9827-3722859E5FB4}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{26B58A9B-DB0B-4E3D-9827-3722859E5FB4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Tests", "StellaOps.Authority.Tests\StellaOps.Authority.Tests.csproj", "{D719B01C-2424-4DAB-94B9-C9B6004F450B}"
|
||||
EndProject
|
||||
@@ -55,6 +55,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Test
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -377,6 +379,18 @@ Global
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1,75 +1,121 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Authority.Bootstrap;
|
||||
|
||||
internal sealed record BootstrapUserRequest
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? InviteToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public bool RequirePasswordReset { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? Roles { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BootstrapClientRequest
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? InviteToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
public bool Confidential { get; init; } = true;
|
||||
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
public string? ClientSecret { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? AllowedGrantTypes { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? AllowedScopes { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? RedirectUris { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? PostLogoutRedirectUris { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Properties { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BootstrapInviteRequest
|
||||
{
|
||||
public string Type { get; init; } = BootstrapInviteTypes.User;
|
||||
|
||||
public string? Token { get; init; }
|
||||
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? Target { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public string? IssuedBy { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
internal static class BootstrapInviteTypes
|
||||
{
|
||||
public const string User = "user";
|
||||
public const string Client = "client";
|
||||
}
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Authority.Bootstrap;
|
||||
|
||||
internal sealed record BootstrapUserRequest
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? InviteToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public bool RequirePasswordReset { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? Roles { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BootstrapClientRequest
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? InviteToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
public bool Confidential { get; init; } = true;
|
||||
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
public string? ClientSecret { get; init; }
|
||||
|
||||
public IReadOnlyCollection<string>? AllowedGrantTypes { get; init; }
|
||||
|
||||
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
|
||||
{
|
||||
public string Type { get; init; } = BootstrapInviteTypes.User;
|
||||
|
||||
public string? Token { get; init; }
|
||||
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? Target { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public string? IssuedBy { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
internal 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";
|
||||
public const string Client = "client";
|
||||
}
|
||||
|
||||
internal sealed record BootstrapInviteRequest
|
||||
{
|
||||
public string Type { get; init; } = BootstrapInviteTypes.User;
|
||||
|
||||
public string? Token { get; init; }
|
||||
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? Target { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public string? IssuedBy { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
internal static class BootstrapInviteTypes
|
||||
{
|
||||
public const string User = "user";
|
||||
public const string Client = "client";
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class AuthorityOpenIddictConstants
|
||||
{
|
||||
internal const string ProviderParameterName = "authority_provider";
|
||||
internal const string ProviderTransactionProperty = "authority:identity_provider";
|
||||
internal const string ClientTransactionProperty = "authority:client";
|
||||
internal const string ClientProviderTransactionProperty = "authority:client_provider";
|
||||
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
|
||||
internal const string TokenTransactionProperty = "authority:token";
|
||||
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
|
||||
internal const string AuditClientIdProperty = "authority:audit_client_id";
|
||||
internal const string AuditProviderProperty = "authority:audit_provider";
|
||||
internal const string AuditConfidentialProperty = "authority:audit_confidential";
|
||||
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
|
||||
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
|
||||
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
|
||||
}
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class AuthorityOpenIddictConstants
|
||||
{
|
||||
internal const string ProviderParameterName = "authority_provider";
|
||||
internal const string ProviderTransactionProperty = "authority:identity_provider";
|
||||
internal const string ClientTransactionProperty = "authority:client";
|
||||
internal const string ClientProviderTransactionProperty = "authority:client_provider";
|
||||
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
|
||||
internal const string TokenTransactionProperty = "authority:token";
|
||||
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
|
||||
internal const string AuditClientIdProperty = "authority:audit_client_id";
|
||||
internal const string AuditProviderProperty = "authority:audit_provider";
|
||||
internal const string AuditConfidentialProperty = "authority:audit_confidential";
|
||||
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
|
||||
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
|
||||
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
|
||||
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";
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +1,142 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleRevocationRequestContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<HandleRevocationRequestHandler> logger;
|
||||
private readonly ActivitySource activitySource;
|
||||
|
||||
public HandleRevocationRequestHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<HandleRevocationRequestHandler> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.HandleRevocationRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.revoke", ActivityKind.Internal);
|
||||
|
||||
var request = context.Request;
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "The revocation request is missing the token parameter.");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = request.Token.Trim();
|
||||
var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
var tokenId = TryExtractTokenId(token);
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
logger.LogDebug("Revocation request for unknown token ignored.");
|
||||
context.HandleRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(document.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await tokenStore.UpdateStatusAsync(
|
||||
document.TokenId,
|
||||
"revoked",
|
||||
clock.GetUtcNow(),
|
||||
"client_request",
|
||||
null,
|
||||
null,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("Token {TokenId} revoked via revocation endpoint.", document.TokenId);
|
||||
activity?.SetTag("authority.token_id", document.TokenId);
|
||||
}
|
||||
|
||||
context.HandleRequest();
|
||||
}
|
||||
|
||||
private static string? TryExtractTokenId(string token)
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = Base64UrlDecode(parts[1]);
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
if (document.RootElement.TryGetProperty("jti", out var jti) && jti.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = jti.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var remainder = value.Length % 4;
|
||||
if (remainder == 2)
|
||||
{
|
||||
value += "==";
|
||||
}
|
||||
else if (remainder == 3)
|
||||
{
|
||||
value += "=";
|
||||
}
|
||||
else if (remainder != 0)
|
||||
{
|
||||
value += new string('=', 4 - remainder);
|
||||
}
|
||||
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleRevocationRequestContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<HandleRevocationRequestHandler> logger;
|
||||
private readonly ActivitySource activitySource;
|
||||
|
||||
public HandleRevocationRequestHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<HandleRevocationRequestHandler> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.HandleRevocationRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.revoke", ActivityKind.Internal);
|
||||
|
||||
var request = context.Request;
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "The revocation request is missing the token parameter.");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = request.Token.Trim();
|
||||
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken, session).ConfigureAwait(false);
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
var tokenId = TryExtractTokenId(token);
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken, session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
logger.LogDebug("Revocation request for unknown token ignored.");
|
||||
context.HandleRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(document.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await tokenStore.UpdateStatusAsync(
|
||||
document.TokenId,
|
||||
"revoked",
|
||||
clock.GetUtcNow(),
|
||||
"client_request",
|
||||
null,
|
||||
null,
|
||||
context.CancellationToken,
|
||||
session).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("Token {TokenId} revoked via revocation endpoint.", document.TokenId);
|
||||
activity?.SetTag("authority.token_id", document.TokenId);
|
||||
}
|
||||
|
||||
context.HandleRequest();
|
||||
}
|
||||
|
||||
private static string? TryExtractTokenId(string token)
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = Base64UrlDecode(parts[1]);
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
if (document.RootElement.TryGetProperty("jti", out var jti) && jti.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = jti.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var remainder = value.Length % 4;
|
||||
if (remainder == 2)
|
||||
{
|
||||
value += "==";
|
||||
}
|
||||
else if (remainder == 3)
|
||||
{
|
||||
value += "=";
|
||||
}
|
||||
else if (remainder != 0)
|
||||
{
|
||||
value += new string('=', 4 - remainder);
|
||||
}
|
||||
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly ILogger<PersistTokensHandler> logger;
|
||||
|
||||
public PersistTokensHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<PersistTokensHandler> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (context.AccessTokenPrincipal is null &&
|
||||
context.RefreshTokenPrincipal is null &&
|
||||
context.AuthorizationCodePrincipal is null &&
|
||||
context.DeviceCodePrincipal is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal);
|
||||
var issuedAt = clock.GetUtcNow();
|
||||
|
||||
if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal)
|
||||
{
|
||||
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal)
|
||||
{
|
||||
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal)
|
||||
{
|
||||
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal)
|
||||
{
|
||||
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
var tokenId = EnsureTokenId(principal);
|
||||
var scopes = ExtractScopes(principal);
|
||||
var document = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = tokenId,
|
||||
Type = tokenType,
|
||||
SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject),
|
||||
ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId),
|
||||
Scope = scopes,
|
||||
Status = "valid",
|
||||
CreatedAt = issuedAt,
|
||||
ExpiresAt = TryGetExpiration(principal)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await tokenStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string EnsureTokenId(ClaimsPrincipal principal)
|
||||
{
|
||||
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
tokenId = Guid.NewGuid().ToString("N");
|
||||
principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
|
||||
}
|
||||
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
private static List<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
=> principal.GetScopes()
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.GetClaim("exp");
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly ILogger<PersistTokensHandler> logger;
|
||||
|
||||
public PersistTokensHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<PersistTokensHandler> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (context.AccessTokenPrincipal is null &&
|
||||
context.RefreshTokenPrincipal is null &&
|
||||
context.AuthorizationCodePrincipal is null &&
|
||||
context.DeviceCodePrincipal is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal);
|
||||
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
var issuedAt = clock.GetUtcNow();
|
||||
|
||||
if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal)
|
||||
{
|
||||
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal)
|
||||
{
|
||||
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal)
|
||||
{
|
||||
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal)
|
||||
{
|
||||
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, IClientSessionHandle session, CancellationToken cancellationToken)
|
||||
{
|
||||
var tokenId = EnsureTokenId(principal);
|
||||
var scopes = ExtractScopes(principal);
|
||||
var document = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = tokenId,
|
||||
Type = tokenType,
|
||||
SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject),
|
||||
ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId),
|
||||
Scope = scopes,
|
||||
Status = "valid",
|
||||
CreatedAt = issuedAt,
|
||||
ExpiresAt = TryGetExpiration(principal)
|
||||
};
|
||||
|
||||
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);
|
||||
logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string EnsureTokenId(ClaimsPrincipal principal)
|
||||
{
|
||||
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
tokenId = Guid.NewGuid().ToString("N");
|
||||
principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
|
||||
}
|
||||
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
private static List<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
=> principal.GetScopes()
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.GetClaim("exp");
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,28 @@ 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;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Authority.Security;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
@@ -30,6 +35,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
|
||||
public ValidateAccessTokenHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
@@ -39,6 +45,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
ILogger<ValidateAccessTokenHandler> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
@@ -74,10 +81,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
? context.TokenId
|
||||
: context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
|
||||
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
AuthorityTokenDocument? tokenDocument = null;
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken).ConfigureAwait(false);
|
||||
tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken, session).ConfigureAwait(false);
|
||||
if (tokenDocument is not null)
|
||||
{
|
||||
if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -99,15 +108,20 @@ 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).ConfigureAwait(false);
|
||||
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
|
||||
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false);
|
||||
if (clientDocument is null || clientDocument.Disabled)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted.");
|
||||
@@ -165,14 +179,15 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
private async ValueTask TrackTokenUsageAsync(
|
||||
OpenIddictServerEvents.ValidateTokenContext context,
|
||||
AuthorityTokenDocument tokenDocument,
|
||||
ClaimsPrincipal principal)
|
||||
ClaimsPrincipal principal,
|
||||
IClientSessionHandle session)
|
||||
{
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
var remoteAddress = metadata?.RemoteIp;
|
||||
var userAgent = metadata?.UserAgent;
|
||||
|
||||
var observedAt = clock.GetUtcNow();
|
||||
var result = await tokenStore.RecordUsageAsync(tokenDocument.TokenId, remoteAddress, userAgent, observedAt, context.CancellationToken)
|
||||
var result = await tokenStore.RecordUsageAsync(tokenDocument.TokenId, remoteAddress, userAgent, observedAt, context.CancellationToken, session)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
switch (result.Status)
|
||||
@@ -264,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
@@ -37,6 +38,11 @@ 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);
|
||||
|
||||
@@ -66,6 +72,15 @@ var authorityConfiguration = StellaOpsAuthorityConfiguration.Build(options =>
|
||||
};
|
||||
});
|
||||
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ConfigureHttpsDefaults(https =>
|
||||
{
|
||||
https.ClientCertificateMode = ClientCertificateMode.AllowCertificate;
|
||||
https.CheckCertificateRevocation = true;
|
||||
});
|
||||
});
|
||||
|
||||
builder.Configuration.AddConfiguration(authorityConfiguration.Configuration);
|
||||
|
||||
builder.Host.UseSerilog((context, _, loggerConfiguration) =>
|
||||
@@ -85,6 +100,52 @@ 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;
|
||||
|
||||
builder.Services.AddOptions<DpopValidationOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.ProofLifetime = senderConstraints.Dpop.ProofLifetime;
|
||||
options.AllowedClockSkew = senderConstraints.Dpop.AllowedClockSkew;
|
||||
options.ReplayWindow = senderConstraints.Dpop.ReplayWindow;
|
||||
|
||||
options.AllowedAlgorithms.Clear();
|
||||
foreach (var algorithm in senderConstraints.Dpop.NormalizedAlgorithms)
|
||||
{
|
||||
options.AllowedAlgorithms.Add(algorithm);
|
||||
}
|
||||
})
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
builder.Services.TryAddSingleton<IDpopReplayCache>(provider => new InMemoryDpopReplayCache(provider.GetService<TimeProvider>()));
|
||||
builder.Services.TryAddSingleton<IDpopProofValidator, DpopProofValidator>();
|
||||
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 =>
|
||||
{
|
||||
@@ -184,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>();
|
||||
@@ -688,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,
|
||||
@@ -695,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);
|
||||
|
||||
@@ -1114,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);
|
||||
}
|
||||
@@ -1,29 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenIddict.Abstractions" Version="6.4.0" />
|
||||
<PackageReference Include="OpenIddict.Server" Version="6.4.0" />
|
||||
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="6.4.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<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" />
|
||||
<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" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<DefineConstants>$(DefineConstants);STELLAOPS_AUTH_SECURITY</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenIddict.Abstractions" Version="6.4.0" />
|
||||
<PackageReference Include="OpenIddict.Server" Version="6.4.0" />
|
||||
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="6.4.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<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" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -15,10 +15,18 @@
|
||||
> Remark (2025-10-14): Background sweep emits invite expiry audits; integration test added.
|
||||
| SEC5.HOST-REPLAY | DONE (2025-10-14) | Security Guild, Zastava | SEC5.E | Persist token usage metadata and surface suspected replay heuristics. | ✅ Validation handlers record device metadata; ✅ Suspected replay flagged via audit/logs; ✅ Tests cover regression cases. |
|
||||
> Remark (2025-10-14): Token validation handler logs suspected replay audits with device metadata; coverage via unit/integration tests.
|
||||
| SEC3.BUILD | DONE (2025-10-11) | Authority Core, Security Guild | SEC3.HOST, FEEDMERGE-COORD-02-900 | Track normalized-range dependency fallout and restore full test matrix once Feedser range primitives land. | ✅ Feedser normalized range libraries merged; ✅ Authority + Configuration test suites (`dotnet test src/StellaOps.Authority.sln`, `dotnet test src/StellaOps.Configuration.Tests/StellaOps.Configuration.Tests.csproj`) pass without Feedser compile failures; ✅ Status recorded here/Sprints (authority-core broadcast not available). |
|
||||
| SEC3.BUILD | DONE (2025-10-11) | Authority Core, Security Guild | SEC3.HOST, FEEDMERGE-COORD-02-900 | Track normalized-range dependency fallout and restore full test matrix once Concelier range primitives land. | ✅ Concelier normalized range libraries merged; ✅ Authority + Configuration test suites (`dotnet test src/StellaOps.Authority.sln`, `dotnet test src/StellaOps.Configuration.Tests/StellaOps.Configuration.Tests.csproj`) pass without Concelier compile failures; ✅ Status recorded here/Sprints (authority-core broadcast not available). |
|
||||
| AUTHCORE-BUILD-OPENIDDICT | DONE (2025-10-14) | Authority Core | SEC2.HOST | Adapt host/audit handlers for OpenIddict 6.4 API surface (no `OpenIddictServerTransaction`) and restore Authority solution build. | ✅ Build `dotnet build src/StellaOps.Authority.sln` succeeds; ✅ Audit correlation + tamper logging verified under new abstractions; ✅ Tests updated. |
|
||||
| AUTHCORE-STORAGE-DEVICE-TOKENS | DONE (2025-10-14) | Authority Core, Storage Guild | AUTHCORE-BUILD-OPENIDDICT | Reintroduce `AuthorityTokenDeviceDocument` + projections removed during refactor so storage layer compiles. | ✅ Document type restored with mappings/migrations; ✅ Storage tests cover device artifacts; ✅ Authority solution build green. |
|
||||
| AUTHCORE-BOOTSTRAP-INVITES | DONE (2025-10-14) | Authority Core, DevOps | AUTHCORE-STORAGE-DEVICE-TOKENS | Wire bootstrap invite cleanup service against restored document schema and re-enable lifecycle tests. | ✅ `BootstrapInviteCleanupService` passes integration tests; ✅ Operator guide updated if behavior changes; ✅ Build/test matrices green. |
|
||||
| AUTHSTORAGE-MONGO-08-001 | TODO | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
|
||||
| AUTHSTORAGE-MONGO-08-001 | DONE (2025-10-19) | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
|
||||
| AUTH-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