up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
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[] { "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);
|
||||
}
|
||||
}
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
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,21 +1,21 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Abstractions.Tests;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
|
||||
public class StellaOpsScopesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(StellaOpsScopes.AdvisoryRead)]
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(StellaOpsScopes.AdvisoryRead)]
|
||||
[InlineData(StellaOpsScopes.AdvisoryIngest)]
|
||||
[InlineData(StellaOpsScopes.AdvisoryAiView)]
|
||||
[InlineData(StellaOpsScopes.AdvisoryAiOperate)]
|
||||
[InlineData(StellaOpsScopes.AdvisoryAiAdmin)]
|
||||
[InlineData(StellaOpsScopes.VexRead)]
|
||||
[InlineData(StellaOpsScopes.VexIngest)]
|
||||
[InlineData(StellaOpsScopes.AocVerify)]
|
||||
[InlineData(StellaOpsScopes.VexRead)]
|
||||
[InlineData(StellaOpsScopes.VexIngest)]
|
||||
[InlineData(StellaOpsScopes.AocVerify)]
|
||||
[InlineData(StellaOpsScopes.SignalsRead)]
|
||||
[InlineData(StellaOpsScopes.SignalsWrite)]
|
||||
[InlineData(StellaOpsScopes.SignalsAdmin)]
|
||||
@@ -25,23 +25,23 @@ public class StellaOpsScopesTests
|
||||
[InlineData(StellaOpsScopes.PolicyWrite)]
|
||||
[InlineData(StellaOpsScopes.PolicyAuthor)]
|
||||
[InlineData(StellaOpsScopes.PolicySubmit)]
|
||||
[InlineData(StellaOpsScopes.PolicyApprove)]
|
||||
[InlineData(StellaOpsScopes.PolicyApprove)]
|
||||
[InlineData(StellaOpsScopes.PolicyReview)]
|
||||
[InlineData(StellaOpsScopes.PolicyOperate)]
|
||||
[InlineData(StellaOpsScopes.PolicyPublish)]
|
||||
[InlineData(StellaOpsScopes.PolicyPromote)]
|
||||
[InlineData(StellaOpsScopes.PolicyAudit)]
|
||||
[InlineData(StellaOpsScopes.PolicyRun)]
|
||||
[InlineData(StellaOpsScopes.PolicySimulate)]
|
||||
[InlineData(StellaOpsScopes.FindingsRead)]
|
||||
[InlineData(StellaOpsScopes.EffectiveWrite)]
|
||||
[InlineData(StellaOpsScopes.PolicyAudit)]
|
||||
[InlineData(StellaOpsScopes.PolicyRun)]
|
||||
[InlineData(StellaOpsScopes.PolicySimulate)]
|
||||
[InlineData(StellaOpsScopes.FindingsRead)]
|
||||
[InlineData(StellaOpsScopes.EffectiveWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphRead)]
|
||||
[InlineData(StellaOpsScopes.VulnView)]
|
||||
[InlineData(StellaOpsScopes.VulnInvestigate)]
|
||||
[InlineData(StellaOpsScopes.VulnOperate)]
|
||||
[InlineData(StellaOpsScopes.VulnAudit)]
|
||||
[InlineData(StellaOpsScopes.VulnRead)]
|
||||
[InlineData(StellaOpsScopes.GraphWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphWrite)]
|
||||
[InlineData(StellaOpsScopes.GraphExport)]
|
||||
[InlineData(StellaOpsScopes.GraphSimulate)]
|
||||
[InlineData(StellaOpsScopes.OrchRead)]
|
||||
@@ -73,8 +73,8 @@ public class StellaOpsScopesTests
|
||||
Assert.Contains(scope, StellaOpsScopes.All);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
|
||||
[Theory]
|
||||
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
|
||||
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
|
||||
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
|
||||
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to console UX features.
|
||||
/// </summary>
|
||||
public const string UiRead = "ui.read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to approve exceptions.
|
||||
/// </summary>
|
||||
public const string ExceptionsApprove = "exceptions:approve";
|
||||
|
||||
/// <summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to console UX features.
|
||||
/// </summary>
|
||||
public const string UiRead = "ui.read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to approve exceptions.
|
||||
/// </summary>
|
||||
public const string ExceptionsApprove = "exceptions:approve";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to raw advisory ingestion data.
|
||||
/// </summary>
|
||||
public const string AdvisoryRead = "advisory:read";
|
||||
@@ -72,34 +72,34 @@ public static class StellaOpsScopes
|
||||
/// Scope granting administrative control over Advisory AI configuration and profiles.
|
||||
/// </summary>
|
||||
public const string AdvisoryAiAdmin = "advisory-ai:admin";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to raw VEX ingestion data.
|
||||
/// </summary>
|
||||
public const string VexRead = "vex:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting write access for raw VEX ingestion.
|
||||
/// </summary>
|
||||
public const string VexIngest = "vex:ingest";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to execute aggregation-only contract verification.
|
||||
/// </summary>
|
||||
public const string AocVerify = "aoc:verify";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to reachability signals.
|
||||
/// </summary>
|
||||
public const string SignalsRead = "signals:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to write reachability signals.
|
||||
/// </summary>
|
||||
public const string SignalsWrite = "signals:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to reachability signal ingestion.
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to raw VEX ingestion data.
|
||||
/// </summary>
|
||||
public const string VexRead = "vex:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting write access for raw VEX ingestion.
|
||||
/// </summary>
|
||||
public const string VexIngest = "vex:ingest";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to execute aggregation-only contract verification.
|
||||
/// </summary>
|
||||
public const string AocVerify = "aoc:verify";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to reachability signals.
|
||||
/// </summary>
|
||||
public const string SignalsRead = "signals:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to write reachability signals.
|
||||
/// </summary>
|
||||
public const string SignalsWrite = "signals:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting administrative access to reachability signal ingestion.
|
||||
/// </summary>
|
||||
public const string SignalsAdmin = "signals:admin";
|
||||
|
||||
@@ -122,38 +122,38 @@ public static class StellaOpsScopes
|
||||
/// Scope granting permission to create or edit policy drafts.
|
||||
/// </summary>
|
||||
public const string PolicyWrite = "policy:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to author Policy Studio workspaces.
|
||||
/// </summary>
|
||||
public const string PolicyAuthor = "policy:author";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to edit policy configurations.
|
||||
/// </summary>
|
||||
public const string PolicyEdit = "policy:edit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to policy metadata.
|
||||
/// </summary>
|
||||
public const string PolicyRead = "policy:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to review Policy Studio drafts.
|
||||
/// </summary>
|
||||
public const string PolicyReview = "policy:review";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to submit drafts for review.
|
||||
/// </summary>
|
||||
public const string PolicySubmit = "policy:submit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to approve or reject policies.
|
||||
/// </summary>
|
||||
public const string PolicyApprove = "policy:approve";
|
||||
|
||||
/// <summary>
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to author Policy Studio workspaces.
|
||||
/// </summary>
|
||||
public const string PolicyAuthor = "policy:author";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to edit policy configurations.
|
||||
/// </summary>
|
||||
public const string PolicyEdit = "policy:edit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to policy metadata.
|
||||
/// </summary>
|
||||
public const string PolicyRead = "policy:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to review Policy Studio drafts.
|
||||
/// </summary>
|
||||
public const string PolicyReview = "policy:review";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to submit drafts for review.
|
||||
/// </summary>
|
||||
public const string PolicySubmit = "policy:submit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to approve or reject policies.
|
||||
/// </summary>
|
||||
public const string PolicyApprove = "policy:approve";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to operate Policy Studio promotions and runs.
|
||||
/// </summary>
|
||||
public const string PolicyOperate = "policy:operate";
|
||||
@@ -172,37 +172,37 @@ public static class StellaOpsScopes
|
||||
/// Scope granting permission to audit Policy Studio activity.
|
||||
/// </summary>
|
||||
public const string PolicyAudit = "policy:audit";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to trigger policy runs and activation workflows.
|
||||
/// </summary>
|
||||
public const string PolicyRun = "policy:run";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to activate policies.
|
||||
/// </summary>
|
||||
public const string PolicyActivate = "policy:activate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to effective findings materialised by Policy Engine.
|
||||
/// </summary>
|
||||
public const string FindingsRead = "findings:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to run Policy Studio simulations.
|
||||
/// </summary>
|
||||
public const string PolicySimulate = "policy:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granted to Policy Engine service identity for writing effective findings.
|
||||
/// </summary>
|
||||
public const string EffectiveWrite = "effective:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to graph queries and overlays.
|
||||
/// </summary>
|
||||
public const string GraphRead = "graph:read";
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to trigger policy runs and activation workflows.
|
||||
/// </summary>
|
||||
public const string PolicyRun = "policy:run";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to activate policies.
|
||||
/// </summary>
|
||||
public const string PolicyActivate = "policy:activate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to effective findings materialised by Policy Engine.
|
||||
/// </summary>
|
||||
public const string FindingsRead = "findings:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to run Policy Studio simulations.
|
||||
/// </summary>
|
||||
public const string PolicySimulate = "policy:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granted to Policy Engine service identity for writing effective findings.
|
||||
/// </summary>
|
||||
public const string EffectiveWrite = "effective:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to graph queries and overlays.
|
||||
/// </summary>
|
||||
public const string GraphRead = "graph:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
|
||||
/// </summary>
|
||||
@@ -269,14 +269,14 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string ObservabilityIncident = "obs:incident";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to export center runs and bundles.
|
||||
/// </summary>
|
||||
public const string ExportViewer = "export.viewer";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to operate export center scheduling and run execution.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to export center runs and bundles.
|
||||
/// </summary>
|
||||
public const string ExportViewer = "export.viewer";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to operate export center scheduling and run execution.
|
||||
/// </summary>
|
||||
public const string ExportOperator = "export.operator";
|
||||
|
||||
/// <summary>
|
||||
@@ -339,27 +339,27 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string PacksApprove = "packs.approve";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to enqueue or mutate graph build jobs.
|
||||
/// </summary>
|
||||
public const string GraphWrite = "graph:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.).
|
||||
/// </summary>
|
||||
public const string GraphExport = "graph:export";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to trigger what-if simulations on graphs.
|
||||
/// </summary>
|
||||
public const string GraphSimulate = "graph:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Orchestrator job state and telemetry.
|
||||
/// </summary>
|
||||
public const string OrchRead = "orch:read";
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// Scope granting permission to enqueue or mutate graph build jobs.
|
||||
/// </summary>
|
||||
public const string GraphWrite = "graph:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.).
|
||||
/// </summary>
|
||||
public const string GraphExport = "graph:export";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to trigger what-if simulations on graphs.
|
||||
/// </summary>
|
||||
public const string GraphSimulate = "graph:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Orchestrator job state and telemetry.
|
||||
/// </summary>
|
||||
public const string OrchRead = "orch:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting permission to execute Orchestrator control actions.
|
||||
/// </summary>
|
||||
public const string OrchOperate = "orch:operate";
|
||||
@@ -374,21 +374,21 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string OrchBackfill = "orch:backfill";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Authority tenant catalog APIs.
|
||||
/// </summary>
|
||||
public const string AuthorityTenantsRead = "authority:tenants.read";
|
||||
|
||||
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
ConcelierJobsTrigger,
|
||||
ConcelierMerge,
|
||||
AuthorityUsersManage,
|
||||
AuthorityClientsManage,
|
||||
AuthorityAuditRead,
|
||||
Bypass,
|
||||
UiRead,
|
||||
ExceptionsApprove,
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to Authority tenant catalog APIs.
|
||||
/// </summary>
|
||||
public const string AuthorityTenantsRead = "authority:tenants.read";
|
||||
|
||||
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
ConcelierJobsTrigger,
|
||||
ConcelierMerge,
|
||||
AuthorityUsersManage,
|
||||
AuthorityClientsManage,
|
||||
AuthorityAuditRead,
|
||||
Bypass,
|
||||
UiRead,
|
||||
ExceptionsApprove,
|
||||
AdvisoryRead,
|
||||
AdvisoryIngest,
|
||||
AdvisoryAiView,
|
||||
@@ -406,8 +406,8 @@ public static class StellaOpsScopes
|
||||
PolicyWrite,
|
||||
PolicyAuthor,
|
||||
PolicyEdit,
|
||||
PolicyRead,
|
||||
PolicyReview,
|
||||
PolicyRead,
|
||||
PolicyReview,
|
||||
PolicySubmit,
|
||||
PolicyApprove,
|
||||
PolicyOperate,
|
||||
@@ -416,9 +416,9 @@ public static class StellaOpsScopes
|
||||
PolicyAudit,
|
||||
PolicyRun,
|
||||
PolicyActivate,
|
||||
PolicySimulate,
|
||||
FindingsRead,
|
||||
EffectiveWrite,
|
||||
PolicySimulate,
|
||||
FindingsRead,
|
||||
EffectiveWrite,
|
||||
GraphRead,
|
||||
VulnView,
|
||||
VulnInvestigate,
|
||||
@@ -458,33 +458,33 @@ public static class StellaOpsScopes
|
||||
OrchQuota,
|
||||
AuthorityTenantsRead
|
||||
};
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
/// <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,27 +1,27 @@
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical identifiers for StellaOps service principals.
|
||||
/// </summary>
|
||||
public static class StellaOpsServiceIdentities
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identity used by Policy Engine when materialising effective findings.
|
||||
/// </summary>
|
||||
public const string PolicyEngine = "policy-engine";
|
||||
|
||||
/// <summary>
|
||||
/// Service identity used by Cartographer when constructing and maintaining graph projections.
|
||||
/// </summary>
|
||||
public const string Cartographer = "cartographer";
|
||||
|
||||
/// <summary>
|
||||
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
|
||||
/// </summary>
|
||||
public const string VulnExplorer = "vuln-explorer";
|
||||
|
||||
/// <summary>
|
||||
/// Service identity used by Signals components when managing reachability facts.
|
||||
/// </summary>
|
||||
public const string Signals = "signals";
|
||||
}
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical identifiers for StellaOps service principals.
|
||||
/// </summary>
|
||||
public static class StellaOpsServiceIdentities
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identity used by Policy Engine when materialising effective findings.
|
||||
/// </summary>
|
||||
public const string PolicyEngine = "policy-engine";
|
||||
|
||||
/// <summary>
|
||||
/// Service identity used by Cartographer when constructing and maintaining graph projections.
|
||||
/// </summary>
|
||||
public const string Cartographer = "cartographer";
|
||||
|
||||
/// <summary>
|
||||
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
|
||||
/// </summary>
|
||||
public const string VulnExplorer = "vuln-explorer";
|
||||
|
||||
/// <summary>
|
||||
/// Service identity used by Signals components when managing reachability facts.
|
||||
/// </summary>
|
||||
public const string Signals = "signals";
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared tenancy default values used across StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsTenancyDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Sentinel value indicating the token is not scoped to a specific project.
|
||||
/// </summary>
|
||||
public const string AnyProject = "*";
|
||||
}
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared tenancy default values used across StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsTenancyDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Sentinel value indicating the token is not scoped to a specific project.
|
||||
/// </summary>
|
||||
public const string AnyProject = "*";
|
||||
}
|
||||
|
||||
@@ -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(" 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);
|
||||
}
|
||||
}
|
||||
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\":\"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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,42 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for requesting tokens from StellaOps Authority.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTokenClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests an access token using the resource owner password credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests an access token using the client credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the cached JWKS document.
|
||||
/// </summary>
|
||||
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a cached token entry.
|
||||
/// </summary>
|
||||
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists a token entry in the cache.
|
||||
/// </summary>
|
||||
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a cached entry.
|
||||
/// </summary>
|
||||
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for requesting tokens from StellaOps Authority.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTokenClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests an access token using the resource owner password credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests an access token using the client credentials flow.
|
||||
/// </summary>
|
||||
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the cached JWKS document.
|
||||
/// </summary>
|
||||
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a cached token entry.
|
||||
/// </summary>
|
||||
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists a token entry in the cache.
|
||||
/// </summary>
|
||||
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a cached entry.
|
||||
/// </summary>
|
||||
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,236 +1,236 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsDiscoveryCache discoveryCache;
|
||||
private readonly StellaOpsJwksCache jwksCache;
|
||||
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
|
||||
private readonly IStellaOpsTokenCache tokenCache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsTokenClient>? logger;
|
||||
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public StellaOpsTokenClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsDiscoveryCache discoveryCache,
|
||||
StellaOpsJwksCache jwksCache,
|
||||
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
|
||||
IStellaOpsTokenCache tokenCache,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<StellaOpsTokenClient>? logger = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
|
||||
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
|
||||
string username,
|
||||
string password,
|
||||
string? scope = null,
|
||||
IReadOnlyDictionary<string, string>? additionalParameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(username);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = username,
|
||||
["password"] = password,
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (string.IsNullOrWhiteSpace(options.ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> jwksCache.GetAsync(cancellationToken);
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.SetAsync(key, entry, cancellationToken);
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(parameters)
|
||||
};
|
||||
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
|
||||
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
|
||||
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("Token response did not contain an access_token.");
|
||||
}
|
||||
|
||||
var expiresIn = document.ExpiresIn ?? 3600;
|
||||
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
|
||||
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
|
||||
|
||||
var result = new StellaOpsTokenResult(
|
||||
document.AccessToken,
|
||||
document.TokenType ?? "Bearer",
|
||||
expiresAt,
|
||||
normalizedScopes,
|
||||
document.RefreshToken,
|
||||
document.IdToken,
|
||||
payload);
|
||||
|
||||
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
|
||||
{
|
||||
var resolvedScope = scope;
|
||||
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
|
||||
{
|
||||
resolvedScope = string.Join(' ', options.NormalizedScopes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedScope))
|
||||
{
|
||||
parameters["scope"] = resolvedScope;
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] ParseScopes(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
unique.Add(part);
|
||||
}
|
||||
|
||||
var result = new string[unique.Count];
|
||||
unique.CopyTo(result);
|
||||
Array.Sort(result, StringComparer.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record TokenResponseDocument(
|
||||
[property: JsonPropertyName("access_token")] string? AccessToken,
|
||||
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
|
||||
[property: JsonPropertyName("id_token")] string? IdToken,
|
||||
[property: JsonPropertyName("token_type")] string? TokenType,
|
||||
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
|
||||
[property: JsonPropertyName("scope")] string? Scope,
|
||||
[property: JsonPropertyName("error")] string? Error,
|
||||
[property: JsonPropertyName("error_description")] string? ErrorDescription);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
|
||||
{
|
||||
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsDiscoveryCache discoveryCache;
|
||||
private readonly StellaOpsJwksCache jwksCache;
|
||||
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
|
||||
private readonly IStellaOpsTokenCache tokenCache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsTokenClient>? logger;
|
||||
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public StellaOpsTokenClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsDiscoveryCache discoveryCache,
|
||||
StellaOpsJwksCache jwksCache,
|
||||
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
|
||||
IStellaOpsTokenCache tokenCache,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<StellaOpsTokenClient>? logger = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
|
||||
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
|
||||
string username,
|
||||
string password,
|
||||
string? scope = null,
|
||||
IReadOnlyDictionary<string, string>? additionalParameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(username);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = username,
|
||||
["password"] = password,
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (string.IsNullOrWhiteSpace(options.ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = options.ClientId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
parameters["client_secret"] = options.ClientSecret;
|
||||
}
|
||||
|
||||
AppendScope(parameters, scope, options);
|
||||
|
||||
if (additionalParameters is not null)
|
||||
{
|
||||
foreach (var (key, value) in additionalParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return RequestTokenAsync(parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
||||
=> jwksCache.GetAsync(cancellationToken);
|
||||
|
||||
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.GetAsync(key, cancellationToken);
|
||||
|
||||
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.SetAsync(key, entry, cancellationToken);
|
||||
|
||||
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> tokenCache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(parameters)
|
||||
};
|
||||
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
|
||||
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
|
||||
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("Token response did not contain an access_token.");
|
||||
}
|
||||
|
||||
var expiresIn = document.ExpiresIn ?? 3600;
|
||||
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
|
||||
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
|
||||
|
||||
var result = new StellaOpsTokenResult(
|
||||
document.AccessToken,
|
||||
document.TokenType ?? "Bearer",
|
||||
expiresAt,
|
||||
normalizedScopes,
|
||||
document.RefreshToken,
|
||||
document.IdToken,
|
||||
payload);
|
||||
|
||||
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
|
||||
{
|
||||
var resolvedScope = scope;
|
||||
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
|
||||
{
|
||||
resolvedScope = string.Join(' ', options.NormalizedScopes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedScope))
|
||||
{
|
||||
parameters["scope"] = resolvedScope;
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] ParseScopes(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
unique.Add(part);
|
||||
}
|
||||
|
||||
var result = new string[unique.Count];
|
||||
unique.CopyTo(result);
|
||||
Array.Sort(result, StringComparer.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record TokenResponseDocument(
|
||||
[property: JsonPropertyName("access_token")] string? AccessToken,
|
||||
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
|
||||
[property: JsonPropertyName("id_token")] string? IdToken,
|
||||
[property: JsonPropertyName("token_type")] string? TokenType,
|
||||
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
|
||||
[property: JsonPropertyName("scope")] string? Scope,
|
||||
[property: JsonPropertyName("error")] string? Error,
|
||||
[property: JsonPropertyName("error_description")] string? ErrorDescription);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
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);
|
||||
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);
|
||||
Assert.IsType<StellaOpsAuthorityConfigurationManager>(jwtOptions.ConfigurationManager);
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
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.RequiredTenants.Add(" Tenant-Alpha ");
|
||||
options.RequiredTenants.Add("tenant-alpha");
|
||||
options.RequiredTenants.Add("Tenant-Beta");
|
||||
|
||||
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.Equal(new[] { "tenant-alpha", "tenant-beta" }, options.NormalizedTenants);
|
||||
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.RequiredTenants.Add(" Tenant-Alpha ");
|
||||
options.RequiredTenants.Add("tenant-alpha");
|
||||
options.RequiredTenants.Add("Tenant-Beta");
|
||||
|
||||
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.Equal(new[] { "tenant-alpha", "tenant-beta" }, options.NormalizedTenants);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,21 @@ using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using OpenIddict.Abstractions;
|
||||
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.RequiredTenants.Add("tenant-alpha");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
|
||||
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.RequiredTenants.Add("tenant-alpha");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
||||
var principal = new StellaOpsPrincipalBuilder()
|
||||
@@ -108,9 +108,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.Validate();
|
||||
@@ -133,9 +133,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Fails_WhenDefaultScopeMissing()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
|
||||
options.Validate();
|
||||
});
|
||||
@@ -162,9 +162,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
|
||||
options.Validate();
|
||||
});
|
||||
@@ -514,24 +514,24 @@ public class StellaOpsScopeAuthorizationHandlerTests
|
||||
{
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,92 +1,92 @@
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for configuring StellaOps resource server authentication.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">Application configuration.</param>
|
||||
/// <param name="configurationSection">
|
||||
/// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding.
|
||||
/// </param>
|
||||
/// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param>
|
||||
public static IServiceCollection AddStellaOpsResourceServerAuthentication(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string? configurationSection = "Authority:ResourceServer",
|
||||
Action<StellaOpsResourceServerOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAuthorization();
|
||||
services.AddStellaOpsScopeHandler();
|
||||
services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
|
||||
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
|
||||
|
||||
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
|
||||
if (!string.IsNullOrWhiteSpace(configurationSection))
|
||||
{
|
||||
optionsBuilder.Bind(configuration.GetSection(configurationSection));
|
||||
}
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configure);
|
||||
}
|
||||
|
||||
optionsBuilder.PostConfigure(static options => options.Validate());
|
||||
|
||||
var authenticationBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
|
||||
{
|
||||
var resourceOptions = monitor.CurrentValue;
|
||||
|
||||
jwt.Authority = resourceOptions.AuthorityUri.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress))
|
||||
{
|
||||
jwt.MetadataAddress = resourceOptions.MetadataAddress;
|
||||
}
|
||||
jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata;
|
||||
jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout;
|
||||
jwt.MapInboundClaims = false;
|
||||
jwt.SaveToken = false;
|
||||
|
||||
jwt.TokenValidationParameters ??= new TokenValidationParameters();
|
||||
jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString();
|
||||
jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0;
|
||||
jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences;
|
||||
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
|
||||
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
|
||||
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
|
||||
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for configuring StellaOps resource server authentication.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">Application configuration.</param>
|
||||
/// <param name="configurationSection">
|
||||
/// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding.
|
||||
/// </param>
|
||||
/// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param>
|
||||
public static IServiceCollection AddStellaOpsResourceServerAuthentication(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string? configurationSection = "Authority:ResourceServer",
|
||||
Action<StellaOpsResourceServerOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAuthorization();
|
||||
services.AddStellaOpsScopeHandler();
|
||||
services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
|
||||
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
|
||||
|
||||
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
|
||||
if (!string.IsNullOrWhiteSpace(configurationSection))
|
||||
{
|
||||
optionsBuilder.Bind(configuration.GetSection(configurationSection));
|
||||
}
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configure);
|
||||
}
|
||||
|
||||
optionsBuilder.PostConfigure(static options => options.Validate());
|
||||
|
||||
var authenticationBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
|
||||
{
|
||||
var resourceOptions = monitor.CurrentValue;
|
||||
|
||||
jwt.Authority = resourceOptions.AuthorityUri.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress))
|
||||
{
|
||||
jwt.MetadataAddress = resourceOptions.MetadataAddress;
|
||||
}
|
||||
jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata;
|
||||
jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout;
|
||||
jwt.MapInboundClaims = false;
|
||||
jwt.SaveToken = false;
|
||||
|
||||
jwt.TokenValidationParameters ??= new TokenValidationParameters();
|
||||
jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString();
|
||||
jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0;
|
||||
jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences;
|
||||
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
|
||||
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
|
||||
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
|
||||
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,116 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
|
||||
/// </summary>
|
||||
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
|
||||
{
|
||||
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
|
||||
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
|
||||
private OpenIdConnectConfiguration? cachedConfiguration;
|
||||
private DateTimeOffset cacheExpiresAt;
|
||||
|
||||
public StellaOpsAuthorityConfigurationManager(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StellaOpsAuthorityConfigurationManager> logger)
|
||||
{
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var current = Volatile.Read(ref cachedConfiguration);
|
||||
if (current is not null && now < cacheExpiresAt)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (cachedConfiguration is not null && now < cacheExpiresAt)
|
||||
{
|
||||
return cachedConfiguration;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var metadataAddress = ResolveMetadataAddress(options);
|
||||
var httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
httpClient.Timeout = options.BackchannelTimeout;
|
||||
|
||||
var retriever = new HttpDocumentRetriever(httpClient)
|
||||
{
|
||||
RequireHttps = options.RequireHttpsMetadata
|
||||
};
|
||||
|
||||
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
|
||||
|
||||
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
|
||||
configuration.Issuer ??= options.AuthorityUri.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
|
||||
{
|
||||
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
|
||||
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
|
||||
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
|
||||
configuration.SigningKeys.Clear();
|
||||
foreach (JsonWebKey key in jsonWebKeySet.Keys)
|
||||
{
|
||||
configuration.SigningKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfiguration = configuration;
|
||||
cacheExpiresAt = now + options.MetadataCacheLifetime;
|
||||
return configuration;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestRefresh()
|
||||
{
|
||||
Volatile.Write(ref cachedConfiguration, null);
|
||||
cacheExpiresAt = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
|
||||
{
|
||||
return options.MetadataAddress;
|
||||
}
|
||||
|
||||
var authority = options.AuthorityUri;
|
||||
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
|
||||
}
|
||||
|
||||
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
|
||||
/// </summary>
|
||||
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
|
||||
{
|
||||
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
|
||||
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
|
||||
private OpenIdConnectConfiguration? cachedConfiguration;
|
||||
private DateTimeOffset cacheExpiresAt;
|
||||
|
||||
public StellaOpsAuthorityConfigurationManager(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StellaOpsAuthorityConfigurationManager> logger)
|
||||
{
|
||||
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var current = Volatile.Read(ref cachedConfiguration);
|
||||
if (current is not null && now < cacheExpiresAt)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (cachedConfiguration is not null && now < cacheExpiresAt)
|
||||
{
|
||||
return cachedConfiguration;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var metadataAddress = ResolveMetadataAddress(options);
|
||||
var httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
httpClient.Timeout = options.BackchannelTimeout;
|
||||
|
||||
var retriever = new HttpDocumentRetriever(httpClient)
|
||||
{
|
||||
RequireHttps = options.RequireHttpsMetadata
|
||||
};
|
||||
|
||||
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
|
||||
|
||||
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
|
||||
configuration.Issuer ??= options.AuthorityUri.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
|
||||
{
|
||||
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
|
||||
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
|
||||
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
|
||||
configuration.SigningKeys.Clear();
|
||||
foreach (JsonWebKey key in jsonWebKeySet.Keys)
|
||||
{
|
||||
configuration.SigningKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfiguration = configuration;
|
||||
cacheExpiresAt = now + options.MetadataCacheLifetime;
|
||||
return configuration;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestRefresh()
|
||||
{
|
||||
Volatile.Write(ref cachedConfiguration, null);
|
||||
cacheExpiresAt = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
|
||||
{
|
||||
return options.MetadataAddress;
|
||||
}
|
||||
|
||||
var authority = options.AuthorityUri;
|
||||
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
|
||||
}
|
||||
|
||||
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling StellaOps resource server authentication.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsResourceServerOptions
|
||||
{
|
||||
private readonly List<string> audiences = new();
|
||||
private readonly List<string> requiredScopes = new();
|
||||
private readonly List<string> requiredTenants = new();
|
||||
private readonly List<string> bypassNetworks = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Authority (issuer) URL that exposes OpenID discovery.
|
||||
/// </summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit OpenID Connect metadata address.
|
||||
/// </summary>
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audiences accepted by the resource server (validated against the <c>aud</c> claim).
|
||||
/// </summary>
|
||||
public IList<string> Audiences => audiences;
|
||||
|
||||
/// <summary>
|
||||
/// Scopes enforced by default authorisation policies.
|
||||
/// </summary>
|
||||
public IList<string> RequiredScopes => requiredScopes;
|
||||
|
||||
/// <summary>
|
||||
/// Tenants permitted to access the resource server (empty list disables tenant checks).
|
||||
/// </summary>
|
||||
public IList<string> RequiredTenants => requiredTenants;
|
||||
|
||||
/// <summary>
|
||||
/// Networks permitted to bypass authentication (used for trusted on-host automation).
|
||||
/// </summary>
|
||||
public IList<string> BypassNetworks => bypassNetworks;
|
||||
|
||||
/// <summary>
|
||||
/// Whether HTTPS metadata is required when communicating with Authority.
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Back-channel timeout when fetching metadata/JWKS.
|
||||
/// </summary>
|
||||
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Clock skew tolerated when validating tokens.
|
||||
/// </summary>
|
||||
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canonical Authority URI (populated during validation).
|
||||
/// </summary>
|
||||
public Uri AuthorityUri { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalised scope list (populated during validation).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalised tenant list (populated during validation).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the network matcher used for bypass checks (populated during validation).
|
||||
/// </summary>
|
||||
public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll;
|
||||
|
||||
/// <summary>
|
||||
/// Validates provided configuration and normalises collections.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server authentication requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata &&
|
||||
!authorityUri.IsLoopback &&
|
||||
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
|
||||
}
|
||||
|
||||
AuthorityUri = authorityUri;
|
||||
|
||||
NormalizeList(audiences, toLower: false);
|
||||
NormalizeList(requiredScopes, toLower: true);
|
||||
NormalizeList(requiredTenants, toLower: true);
|
||||
NormalizeList(bypassNetworks, toLower: false);
|
||||
|
||||
NormalizedScopes = requiredScopes.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
|
||||
|
||||
NormalizedTenants = requiredTenants.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
|
||||
|
||||
BypassMatcher = bypassNetworks.Count == 0
|
||||
? NetworkMaskMatcher.DenyAll
|
||||
: new NetworkMaskMatcher(bypassNetworks);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, bool toLower)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var value = values[index];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (toLower)
|
||||
{
|
||||
trimmed = trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!seen.Add(trimmed))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.ServerIntegration;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling StellaOps resource server authentication.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsResourceServerOptions
|
||||
{
|
||||
private readonly List<string> audiences = new();
|
||||
private readonly List<string> requiredScopes = new();
|
||||
private readonly List<string> requiredTenants = new();
|
||||
private readonly List<string> bypassNetworks = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Authority (issuer) URL that exposes OpenID discovery.
|
||||
/// </summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit OpenID Connect metadata address.
|
||||
/// </summary>
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audiences accepted by the resource server (validated against the <c>aud</c> claim).
|
||||
/// </summary>
|
||||
public IList<string> Audiences => audiences;
|
||||
|
||||
/// <summary>
|
||||
/// Scopes enforced by default authorisation policies.
|
||||
/// </summary>
|
||||
public IList<string> RequiredScopes => requiredScopes;
|
||||
|
||||
/// <summary>
|
||||
/// Tenants permitted to access the resource server (empty list disables tenant checks).
|
||||
/// </summary>
|
||||
public IList<string> RequiredTenants => requiredTenants;
|
||||
|
||||
/// <summary>
|
||||
/// Networks permitted to bypass authentication (used for trusted on-host automation).
|
||||
/// </summary>
|
||||
public IList<string> BypassNetworks => bypassNetworks;
|
||||
|
||||
/// <summary>
|
||||
/// Whether HTTPS metadata is required when communicating with Authority.
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Back-channel timeout when fetching metadata/JWKS.
|
||||
/// </summary>
|
||||
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Clock skew tolerated when validating tokens.
|
||||
/// </summary>
|
||||
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canonical Authority URI (populated during validation).
|
||||
/// </summary>
|
||||
public Uri AuthorityUri { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalised scope list (populated during validation).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalised tenant list (populated during validation).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the network matcher used for bypass checks (populated during validation).
|
||||
/// </summary>
|
||||
public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll;
|
||||
|
||||
/// <summary>
|
||||
/// Validates provided configuration and normalises collections.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Authority))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server authentication requires an Authority URL.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata &&
|
||||
!authorityUri.IsLoopback &&
|
||||
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
|
||||
}
|
||||
|
||||
AuthorityUri = authorityUri;
|
||||
|
||||
NormalizeList(audiences, toLower: false);
|
||||
NormalizeList(requiredScopes, toLower: true);
|
||||
NormalizeList(requiredTenants, toLower: true);
|
||||
NormalizeList(bypassNetworks, toLower: false);
|
||||
|
||||
NormalizedScopes = requiredScopes.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
|
||||
|
||||
NormalizedTenants = requiredTenants.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
|
||||
|
||||
BypassMatcher = bypassNetworks.Count == 0
|
||||
? NetworkMaskMatcher.DenyAll
|
||||
: new NetworkMaskMatcher(bypassNetworks);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, bool toLower)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var index = values.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var value = values[index];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (toLower)
|
||||
{
|
||||
trimmed = trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!seen.Add(trimmed))
|
||||
{
|
||||
values.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
values[index] = trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
|
||||
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.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
|
||||
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Tests.Credentials;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
@@ -11,8 +11,8 @@ using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Credentials;
|
||||
|
||||
@@ -9,7 +9,7 @@ using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Credentials;
|
||||
using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Authority.Storage.InMemory\\StellaOps.Authority.Storage.InMemory.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
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;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardClientProvisioningStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "bootstrap-client",
|
||||
confidential: true,
|
||||
displayName: "Bootstrap",
|
||||
clientSecret: "SuperSecret1!",
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "scopeA" });
|
||||
|
||||
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
|
||||
Assert.Equal("standard", document.Plugin);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal("bootstrap-client", descriptor!.ClientId);
|
||||
Assert.True(descriptor.Confidential);
|
||||
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
|
||||
Assert.Contains("scopea", descriptor.AllowedScopes);
|
||||
}
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardClientProvisioningStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "bootstrap-client",
|
||||
confidential: true,
|
||||
displayName: "Bootstrap",
|
||||
clientSecret: "SuperSecret1!",
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "scopeA" });
|
||||
|
||||
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
|
||||
Assert.Equal("standard", document.Plugin);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal("bootstrap-client", descriptor!.ClientId);
|
||||
Assert.True(descriptor.Confidential);
|
||||
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
|
||||
Assert.Contains("scopea", descriptor.AllowedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_NormalisesTenant()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "tenant-client",
|
||||
confidential: false,
|
||||
displayName: "Tenant Client",
|
||||
clientSecret: null,
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "scopeA" },
|
||||
tenant: " Tenant-Alpha " );
|
||||
|
||||
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal("tenant-alpha", descriptor!.Tenant);
|
||||
}
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "tenant-client",
|
||||
confidential: false,
|
||||
displayName: "Tenant Client",
|
||||
clientSecret: null,
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "scopeA" },
|
||||
tenant: " Tenant-Alpha " );
|
||||
|
||||
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal("tenant-alpha", descriptor!.Tenant);
|
||||
}
|
||||
[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);
|
||||
Assert.True(store.Documents.TryGetValue("signer", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.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);
|
||||
|
||||
Assert.True(store.Documents.TryGetValue("mtls-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
Documents.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
var removed = Documents.Remove(clientId);
|
||||
return ValueTask.FromResult(removed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
public List<AuthorityRevocationDocument> Upserts { get; } = new();
|
||||
|
||||
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, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(true);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
}
|
||||
{
|
||||
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);
|
||||
Assert.True(store.Documents.TryGetValue("signer", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.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);
|
||||
|
||||
Assert.True(store.Documents.TryGetValue("mtls-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
Documents.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
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, IClientSessionHandle? session = null)
|
||||
{
|
||||
var removed = Documents.Remove(clientId);
|
||||
return ValueTask.FromResult(removed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
public List<AuthorityRevocationDocument> Upserts { get; } = new();
|
||||
|
||||
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, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(true);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
@@ -24,7 +24,7 @@ public class StandardPluginRegistrarTests
|
||||
[Fact]
|
||||
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
|
||||
{
|
||||
var client = new InMemoryMongoClient();
|
||||
var client = new InMemoryClient();
|
||||
var database = client.GetDatabase("registrar-tests");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
@@ -86,7 +86,7 @@ public class StandardPluginRegistrarTests
|
||||
[Fact]
|
||||
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
|
||||
{
|
||||
var client = new InMemoryMongoClient();
|
||||
var client = new InMemoryClient();
|
||||
var database = client.GetDatabase("registrar-password-policy");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
@@ -131,7 +131,7 @@ public class StandardPluginRegistrarTests
|
||||
[Fact]
|
||||
public void Register_ForcesPasswordCapability_WhenManifestMissing()
|
||||
{
|
||||
var client = new InMemoryMongoClient();
|
||||
var client = new InMemoryClient();
|
||||
var database = client.GetDatabase("registrar-capabilities");
|
||||
|
||||
var configuration = new ConfigurationBuilder().Build();
|
||||
@@ -163,7 +163,7 @@ public class StandardPluginRegistrarTests
|
||||
[Fact]
|
||||
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
|
||||
{
|
||||
var client = new InMemoryMongoClient();
|
||||
var client = new InMemoryClient();
|
||||
var database = client.GetDatabase("registrar-bootstrap-validation");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
@@ -197,7 +197,7 @@ public class StandardPluginRegistrarTests
|
||||
[Fact]
|
||||
public void Register_NormalizesTokenSigningKeyDirectory()
|
||||
{
|
||||
var client = new InMemoryMongoClient();
|
||||
var client = new InMemoryClient();
|
||||
var database = client.GetDatabase("registrar-token-signing");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
@@ -389,7 +389,7 @@ internal sealed class TestAuthEventSink : IAuthEventSink
|
||||
internal static class StandardPluginRegistrarTestHelpers
|
||||
{
|
||||
public static ServiceCollection CreateServiceCollection(
|
||||
IMongoDatabase database,
|
||||
IDatabase database,
|
||||
IAuthEventSink? authEventSink = null,
|
||||
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
@@ -16,14 +16,14 @@ namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly IMongoDatabase database;
|
||||
private readonly IDatabase database;
|
||||
private readonly StandardPluginOptions options;
|
||||
private readonly StandardUserCredentialStore store;
|
||||
private readonly TestAuditLogger auditLogger;
|
||||
|
||||
public StandardUserCredentialStoreTests()
|
||||
{
|
||||
var client = new InMemoryMongoClient();
|
||||
var client = new InMemoryClient();
|
||||
database = client.GetDatabase("authority-tests");
|
||||
options = new StandardPluginOptions
|
||||
{
|
||||
@@ -171,9 +171,9 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Assert.True(auditEntry.Success);
|
||||
Assert.Equal("legacy", auditEntry.Username);
|
||||
|
||||
var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.Find(u => u.NormalizedUsername == "legacy")
|
||||
.FirstOrDefaultAsync();
|
||||
var results = await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.FindAsync(u => u.NormalizedUsername == "legacy");
|
||||
var updated = results.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
|
||||
internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ILogger<StandardPluginBootstrapper> logger;
|
||||
|
||||
public StandardPluginBootstrapper(
|
||||
string pluginName,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<StandardPluginBootstrapper> logger)
|
||||
{
|
||||
this.pluginName = pluginName;
|
||||
this.scopeFactory = scopeFactory;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
|
||||
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
|
||||
internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ILogger<StandardPluginBootstrapper> logger;
|
||||
|
||||
public StandardPluginBootstrapper(
|
||||
string pluginName,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<StandardPluginBootstrapper> logger)
|
||||
{
|
||||
this.pluginName = pluginName;
|
||||
this.scopeFactory = scopeFactory;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
|
||||
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
private const string DefaultTenantId = "default";
|
||||
|
||||
public string PluginType => "standard";
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var pluginName = context.Plugin.Manifest.Name;
|
||||
|
||||
context.Services.AddSingleton<StandardClaimsEnricher>();
|
||||
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
|
||||
|
||||
context.Services.AddStellaOpsCrypto();
|
||||
|
||||
var configPath = context.Plugin.Manifest.ConfigPath;
|
||||
|
||||
context.Services.AddOptions<StandardPluginOptions>(pluginName)
|
||||
.Bind(context.Plugin.Configuration)
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Normalize(configPath);
|
||||
options.Validate(pluginName);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var userRepository = sp.GetRequiredService<IUserRepository>();
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var pluginOptions = optionsMonitor.Get(pluginName);
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
{
|
||||
registrarLogger.LogWarning(
|
||||
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
|
||||
pluginName,
|
||||
pluginOptions.PasswordPolicy.MinimumLength,
|
||||
pluginOptions.PasswordPolicy.RequireUppercase,
|
||||
pluginOptions.PasswordPolicy.RequireLowercase,
|
||||
pluginOptions.PasswordPolicy.RequireDigit,
|
||||
pluginOptions.PasswordPolicy.RequireSymbol,
|
||||
baselinePolicy.MinimumLength,
|
||||
baselinePolicy.RequireUppercase,
|
||||
baselinePolicy.RequireLowercase,
|
||||
baselinePolicy.RequireDigit,
|
||||
baselinePolicy.RequireSymbol);
|
||||
}
|
||||
|
||||
// Use tenant from options or default
|
||||
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
pluginName,
|
||||
tenantId,
|
||||
userRepository,
|
||||
pluginOptions,
|
||||
passwordHasher,
|
||||
auditLogger,
|
||||
loggerFactory.CreateLogger<StandardUserCredentialStore>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
|
||||
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
|
||||
{
|
||||
var store = sp.GetRequiredService<StandardUserCredentialStore>();
|
||||
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new StandardIdentityProviderPlugin(
|
||||
context.Plugin,
|
||||
store,
|
||||
clientProvisioningStore,
|
||||
sp.GetRequiredService<StandardClaimsEnricher>(),
|
||||
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IClientProvisioningStore>(sp =>
|
||||
sp.GetRequiredService<StandardClientProvisioningStore>());
|
||||
|
||||
context.Services.AddSingleton<IHostedService>(sp =>
|
||||
new StandardPluginBootstrapper(
|
||||
pluginName,
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
private const string DefaultTenantId = "default";
|
||||
|
||||
public string PluginType => "standard";
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var pluginName = context.Plugin.Manifest.Name;
|
||||
|
||||
context.Services.AddSingleton<StandardClaimsEnricher>();
|
||||
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
|
||||
|
||||
context.Services.AddStellaOpsCrypto();
|
||||
|
||||
var configPath = context.Plugin.Manifest.ConfigPath;
|
||||
|
||||
context.Services.AddOptions<StandardPluginOptions>(pluginName)
|
||||
.Bind(context.Plugin.Configuration)
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Normalize(configPath);
|
||||
options.Validate(pluginName);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var userRepository = sp.GetRequiredService<IUserRepository>();
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var pluginOptions = optionsMonitor.Get(pluginName);
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
{
|
||||
registrarLogger.LogWarning(
|
||||
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
|
||||
pluginName,
|
||||
pluginOptions.PasswordPolicy.MinimumLength,
|
||||
pluginOptions.PasswordPolicy.RequireUppercase,
|
||||
pluginOptions.PasswordPolicy.RequireLowercase,
|
||||
pluginOptions.PasswordPolicy.RequireDigit,
|
||||
pluginOptions.PasswordPolicy.RequireSymbol,
|
||||
baselinePolicy.MinimumLength,
|
||||
baselinePolicy.RequireUppercase,
|
||||
baselinePolicy.RequireLowercase,
|
||||
baselinePolicy.RequireDigit,
|
||||
baselinePolicy.RequireSymbol);
|
||||
}
|
||||
|
||||
// Use tenant from options or default
|
||||
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
pluginName,
|
||||
tenantId,
|
||||
userRepository,
|
||||
pluginOptions,
|
||||
passwordHasher,
|
||||
auditLogger,
|
||||
loggerFactory.CreateLogger<StandardUserCredentialStore>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
|
||||
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
|
||||
{
|
||||
var store = sp.GetRequiredService<StandardUserCredentialStore>();
|
||||
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new StandardIdentityProviderPlugin(
|
||||
context.Plugin,
|
||||
store,
|
||||
clientProvisioningStore,
|
||||
sp.GetRequiredService<StandardClaimsEnricher>(),
|
||||
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IClientProvisioningStore>(sp =>
|
||||
sp.GetRequiredService<StandardClientProvisioningStore>());
|
||||
|
||||
context.Services.AddSingleton<IHostedService>(sp =>
|
||||
new StandardPluginBootstrapper(
|
||||
pluginName,
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.InMemory\StellaOps.Authority.Storage.InMemory.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
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();
|
||||
}
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.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;
|
||||
@@ -79,113 +79,113 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
{
|
||||
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
@@ -207,42 +207,42 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
|
||||
|
||||
public class AuthorityClientRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_Throws_WhenClientIdMissing()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration(string.Empty, false, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RequiresSecret_ForConfidentialClients()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration("cli", true, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClientSecret_ReturnsCopy()
|
||||
{
|
||||
var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha");
|
||||
|
||||
var updated = registration.WithClientSecret("secret");
|
||||
|
||||
Assert.Equal("cli", updated.ClientId);
|
||||
Assert.Equal("secret", updated.ClientSecret);
|
||||
Assert.False(updated.Confidential);
|
||||
Assert.Equal("tenant-alpha", updated.Tenant);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
|
||||
|
||||
public class AuthorityClientRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_Throws_WhenClientIdMissing()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration(string.Empty, false, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RequiresSecret_ForConfidentialClients()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration("cli", true, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithClientSecret_ReturnsCopy()
|
||||
{
|
||||
var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha");
|
||||
|
||||
var updated = registration.WithClientSecret("secret");
|
||||
|
||||
Assert.Equal("cli", updated.ClientId);
|
||||
Assert.Equal("secret", updated.ClientSecret);
|
||||
Assert.False(updated.Confidential);
|
||||
Assert.Equal("tenant-alpha", updated.Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known Authority plugin capability identifiers.
|
||||
/// </summary>
|
||||
public static class AuthorityPluginCapabilities
|
||||
{
|
||||
public const string Password = "password";
|
||||
public const string Bootstrap = "bootstrap";
|
||||
public const string Mfa = "mfa";
|
||||
public const string ClientProvisioning = "clientProvisioning";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable description of an Authority plugin loaded from configuration.
|
||||
/// </summary>
|
||||
/// <param name="Name">Logical name derived from configuration key.</param>
|
||||
/// <param name="Type">Plugin type identifier (used for capability routing).</param>
|
||||
/// <param name="Enabled">Whether the plugin is enabled.</param>
|
||||
/// <param name="AssemblyName">Assembly name without extension.</param>
|
||||
/// <param name="AssemblyPath">Explicit assembly path override.</param>
|
||||
/// <param name="Capabilities">Capability hints exposed by the plugin.</param>
|
||||
/// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param>
|
||||
/// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param>
|
||||
public sealed record AuthorityPluginManifest(
|
||||
string Name,
|
||||
string Type,
|
||||
bool Enabled,
|
||||
string? AssemblyName,
|
||||
string? AssemblyPath,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyDictionary<string, string?> Metadata,
|
||||
string ConfigPath)
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the manifest declares the specified capability.
|
||||
/// </summary>
|
||||
/// <param name="capability">Capability identifier to check.</param>
|
||||
public bool HasCapability(string capability)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(capability))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var entry in Capabilities)
|
||||
{
|
||||
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime context combining plugin manifest metadata and its bound configuration.
|
||||
/// </summary>
|
||||
/// <param name="Manifest">Manifest describing the plugin.</param>
|
||||
/// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param>
|
||||
public sealed record AuthorityPluginContext(
|
||||
AuthorityPluginManifest Manifest,
|
||||
IConfiguration Configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Registry exposing the set of Authority plugins loaded at runtime.
|
||||
/// </summary>
|
||||
public interface IAuthorityPluginRegistry
|
||||
{
|
||||
IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
|
||||
|
||||
bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context);
|
||||
|
||||
AuthorityPluginContext GetRequired(string name)
|
||||
{
|
||||
if (TryGet(name, out var context))
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"Authority plugin '{name}' is not registered.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry exposing loaded identity provider plugins and their capabilities.
|
||||
/// </summary>
|
||||
public interface IAuthorityIdentityProviderRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets metadata for all registered identity provider plugins.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise password support.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise multi-factor authentication support.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known Authority plugin capability identifiers.
|
||||
/// </summary>
|
||||
public static class AuthorityPluginCapabilities
|
||||
{
|
||||
public const string Password = "password";
|
||||
public const string Bootstrap = "bootstrap";
|
||||
public const string Mfa = "mfa";
|
||||
public const string ClientProvisioning = "clientProvisioning";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable description of an Authority plugin loaded from configuration.
|
||||
/// </summary>
|
||||
/// <param name="Name">Logical name derived from configuration key.</param>
|
||||
/// <param name="Type">Plugin type identifier (used for capability routing).</param>
|
||||
/// <param name="Enabled">Whether the plugin is enabled.</param>
|
||||
/// <param name="AssemblyName">Assembly name without extension.</param>
|
||||
/// <param name="AssemblyPath">Explicit assembly path override.</param>
|
||||
/// <param name="Capabilities">Capability hints exposed by the plugin.</param>
|
||||
/// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param>
|
||||
/// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param>
|
||||
public sealed record AuthorityPluginManifest(
|
||||
string Name,
|
||||
string Type,
|
||||
bool Enabled,
|
||||
string? AssemblyName,
|
||||
string? AssemblyPath,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyDictionary<string, string?> Metadata,
|
||||
string ConfigPath)
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the manifest declares the specified capability.
|
||||
/// </summary>
|
||||
/// <param name="capability">Capability identifier to check.</param>
|
||||
public bool HasCapability(string capability)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(capability))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var entry in Capabilities)
|
||||
{
|
||||
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime context combining plugin manifest metadata and its bound configuration.
|
||||
/// </summary>
|
||||
/// <param name="Manifest">Manifest describing the plugin.</param>
|
||||
/// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param>
|
||||
public sealed record AuthorityPluginContext(
|
||||
AuthorityPluginManifest Manifest,
|
||||
IConfiguration Configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Registry exposing the set of Authority plugins loaded at runtime.
|
||||
/// </summary>
|
||||
public interface IAuthorityPluginRegistry
|
||||
{
|
||||
IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
|
||||
|
||||
bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context);
|
||||
|
||||
AuthorityPluginContext GetRequired(string name)
|
||||
{
|
||||
if (TryGet(name, out var context))
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"Authority plugin '{name}' is not registered.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry exposing loaded identity provider plugins and their capabilities.
|
||||
/// </summary>
|
||||
public interface IAuthorityIdentityProviderRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets metadata for all registered identity provider plugins.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise password support.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise multi-factor authentication support.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise client provisioning support.
|
||||
/// </summary>
|
||||
@@ -126,91 +126,91 @@ public interface IAuthorityIdentityProviderRegistry
|
||||
/// Aggregate capability flags across all registered providers.
|
||||
/// </summary>
|
||||
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve identity provider metadata by name.
|
||||
/// </summary>
|
||||
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves identity provider metadata by name or throws when not found.
|
||||
/// </summary>
|
||||
AuthorityIdentityProviderMetadata GetRequired(string name)
|
||||
{
|
||||
if (TryGet(name, out var metadata))
|
||||
{
|
||||
return metadata;
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a scoped handle to the specified identity provider.
|
||||
/// </summary>
|
||||
/// <param name="name">Logical provider name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Handle managing the provider instance lifetime.</returns>
|
||||
ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable metadata describing a registered identity provider.
|
||||
/// </summary>
|
||||
/// <param name="Name">Logical provider name from the manifest.</param>
|
||||
/// <param name="Type">Provider type identifier.</param>
|
||||
/// <param name="Capabilities">Capability flags advertised by the provider.</param>
|
||||
public sealed record AuthorityIdentityProviderMetadata(
|
||||
string Name,
|
||||
string Type,
|
||||
AuthorityIdentityProviderCapabilities Capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a scoped identity provider instance and manages its disposal.
|
||||
/// </summary>
|
||||
public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private readonly AsyncServiceScope scope;
|
||||
private bool disposed;
|
||||
|
||||
public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider)
|
||||
{
|
||||
this.scope = scope;
|
||||
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
|
||||
Provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the metadata associated with the provider instance.
|
||||
/// </summary>
|
||||
public AuthorityIdentityProviderMetadata Metadata { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active provider instance.
|
||||
/// </summary>
|
||||
public IIdentityProviderPlugin Provider { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
scope.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve identity provider metadata by name.
|
||||
/// </summary>
|
||||
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves identity provider metadata by name or throws when not found.
|
||||
/// </summary>
|
||||
AuthorityIdentityProviderMetadata GetRequired(string name)
|
||||
{
|
||||
if (TryGet(name, out var metadata))
|
||||
{
|
||||
return metadata;
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a scoped handle to the specified identity provider.
|
||||
/// </summary>
|
||||
/// <param name="name">Logical provider name.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Handle managing the provider instance lifetime.</returns>
|
||||
ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable metadata describing a registered identity provider.
|
||||
/// </summary>
|
||||
/// <param name="Name">Logical provider name from the manifest.</param>
|
||||
/// <param name="Type">Provider type identifier.</param>
|
||||
/// <param name="Capabilities">Capability flags advertised by the provider.</param>
|
||||
public sealed record AuthorityIdentityProviderMetadata(
|
||||
string Name,
|
||||
string Type,
|
||||
AuthorityIdentityProviderCapabilities Capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a scoped identity provider instance and manages its disposal.
|
||||
/// </summary>
|
||||
public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private readonly AsyncServiceScope scope;
|
||||
private bool disposed;
|
||||
|
||||
public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider)
|
||||
{
|
||||
this.scope = scope;
|
||||
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
|
||||
Provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the metadata associated with the provider instance.
|
||||
/// </summary>
|
||||
public AuthorityIdentityProviderMetadata Metadata { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active provider instance.
|
||||
/// </summary>
|
||||
public IIdentityProviderPlugin Provider { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
scope.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace MongoDB.Driver;
|
||||
namespace StellaOps.Authority.InMemoryDriver;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for MongoDB IMongoCollection interface.
|
||||
/// In PostgreSQL mode, this provides an in-memory implementation.
|
||||
/// Compatibility shim for collection interface.
|
||||
/// Provides an in-memory implementation.
|
||||
/// </summary>
|
||||
public interface IMongoCollection<TDocument>
|
||||
public interface ICollection<TDocument>
|
||||
{
|
||||
IMongoDatabase Database { get; }
|
||||
IDatabase Database { get; }
|
||||
string CollectionNamespace { get; }
|
||||
|
||||
Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
|
||||
@@ -20,38 +20,38 @@ public interface IMongoCollection<TDocument>
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for MongoDB IMongoDatabase interface.
|
||||
/// Compatibility shim for database interface.
|
||||
/// </summary>
|
||||
public interface IMongoDatabase
|
||||
public interface IDatabase
|
||||
{
|
||||
string DatabaseNamespace { get; }
|
||||
IMongoCollection<TDocument> GetCollection<TDocument>(string name);
|
||||
ICollection<TDocument> GetCollection<TDocument>(string name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for MongoDB IMongoClient interface.
|
||||
/// Compatibility shim for client interface.
|
||||
/// </summary>
|
||||
public interface IMongoClient
|
||||
public interface IClient
|
||||
{
|
||||
IMongoDatabase GetDatabase(string name);
|
||||
IDatabase GetDatabase(string name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IMongoCollection for compatibility.
|
||||
/// In-memory implementation of ICollection for compatibility.
|
||||
/// </summary>
|
||||
public class InMemoryMongoCollection<TDocument> : IMongoCollection<TDocument>
|
||||
public class InMemoryCollection<TDocument> : ICollection<TDocument>
|
||||
{
|
||||
private readonly List<TDocument> _documents = new();
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly IDatabase _database;
|
||||
private readonly string _name;
|
||||
|
||||
public InMemoryMongoCollection(IMongoDatabase database, string name)
|
||||
public InMemoryCollection(IDatabase database, string name)
|
||||
{
|
||||
_database = database;
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public IMongoDatabase Database => _database;
|
||||
public IDatabase Database => _database;
|
||||
public string CollectionNamespace => _name;
|
||||
|
||||
public Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
|
||||
@@ -109,43 +109,43 @@ public class InMemoryMongoCollection<TDocument> : IMongoCollection<TDocument>
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IMongoDatabase for compatibility.
|
||||
/// In-memory implementation of IDatabase for compatibility.
|
||||
/// </summary>
|
||||
public class InMemoryMongoDatabase : IMongoDatabase
|
||||
public class InMemoryDatabase : IDatabase
|
||||
{
|
||||
private readonly Dictionary<string, object> _collections = new();
|
||||
private readonly string _name;
|
||||
|
||||
public InMemoryMongoDatabase(string name)
|
||||
public InMemoryDatabase(string name)
|
||||
{
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public string DatabaseNamespace => _name;
|
||||
|
||||
public IMongoCollection<TDocument> GetCollection<TDocument>(string name)
|
||||
public ICollection<TDocument> GetCollection<TDocument>(string name)
|
||||
{
|
||||
if (!_collections.TryGetValue(name, out var collection))
|
||||
{
|
||||
collection = new InMemoryMongoCollection<TDocument>(this, name);
|
||||
collection = new InMemoryCollection<TDocument>(this, name);
|
||||
_collections[name] = collection;
|
||||
}
|
||||
return (IMongoCollection<TDocument>)collection;
|
||||
return (ICollection<TDocument>)collection;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IMongoClient for compatibility.
|
||||
/// In-memory implementation of IClient for compatibility.
|
||||
/// </summary>
|
||||
public class InMemoryMongoClient : IMongoClient
|
||||
public class InMemoryClient : IClient
|
||||
{
|
||||
private readonly Dictionary<string, IMongoDatabase> _databases = new();
|
||||
private readonly Dictionary<string, IDatabase> _databases = new();
|
||||
|
||||
public IMongoDatabase GetDatabase(string name)
|
||||
public IDatabase GetDatabase(string name)
|
||||
{
|
||||
if (!_databases.TryGetValue(name, out var database))
|
||||
{
|
||||
database = new InMemoryMongoDatabase(name);
|
||||
database = new InMemoryDatabase(name);
|
||||
_databases[name] = database;
|
||||
}
|
||||
return database;
|
||||
@@ -1,15 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Storage.InMemory.Initialization;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim storage options. In PostgreSQL mode, these are largely unused.
|
||||
/// </summary>
|
||||
public sealed class AuthorityMongoStorageOptions
|
||||
public sealed class AuthorityStorageOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
public string DatabaseName { get; set; } = "authority";
|
||||
@@ -28,9 +28,9 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAuthorityMongoStorage(
|
||||
this IServiceCollection services,
|
||||
Action<AuthorityMongoStorageOptions> configureOptions)
|
||||
Action<AuthorityStorageOptions> configureOptions)
|
||||
{
|
||||
var options = new AuthorityMongoStorageOptions();
|
||||
var options = new AuthorityStorageOptions();
|
||||
configureOptions(options);
|
||||
services.AddSingleton(options);
|
||||
|
||||
@@ -38,19 +38,19 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterMongoCompatServices(IServiceCollection services, AuthorityMongoStorageOptions options)
|
||||
private static void RegisterMongoCompatServices(IServiceCollection services, AuthorityStorageOptions options)
|
||||
{
|
||||
// Register the initializer (no-op for Postgres mode)
|
||||
services.AddSingleton<AuthorityMongoInitializer>();
|
||||
services.AddSingleton<AuthorityStorageInitializer>();
|
||||
|
||||
// Register null session accessor
|
||||
services.AddSingleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>();
|
||||
services.AddSingleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>();
|
||||
|
||||
// Register in-memory MongoDB shims for compatibility
|
||||
var inMemoryClient = new InMemoryMongoClient();
|
||||
// Register in-memory shims for compatibility
|
||||
var inMemoryClient = new InMemoryClient();
|
||||
var inMemoryDatabase = inMemoryClient.GetDatabase(options.DatabaseName);
|
||||
services.AddSingleton<IMongoClient>(inMemoryClient);
|
||||
services.AddSingleton<IMongoDatabase>(inMemoryDatabase);
|
||||
services.AddSingleton<IClient>(inMemoryClient);
|
||||
services.AddSingleton<IDatabase>(inMemoryDatabase);
|
||||
|
||||
// Register in-memory store implementations
|
||||
// These should be replaced by Postgres-backed implementations over time
|
||||
@@ -1,10 +1,10 @@
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
namespace StellaOps.Authority.Storage.InMemory.Initialization;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for MongoDB initializer. In PostgreSQL mode, this is a no-op.
|
||||
/// Compatibility shim for storage initializer. In PostgreSQL mode, this is a no-op.
|
||||
/// The actual initialization is handled by PostgreSQL migrations.
|
||||
/// </summary>
|
||||
public sealed class AuthorityMongoInitializer
|
||||
public sealed class AuthorityStorageInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the database. In PostgreSQL mode, this is a no-op as migrations handle setup.
|
||||
@@ -8,9 +8,9 @@ public interface IClientSessionHandle : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for MongoDB session accessor. In PostgreSQL mode, this returns null.
|
||||
/// Compatibility shim for database session accessor. In PostgreSQL mode, this returns null.
|
||||
/// </summary>
|
||||
public interface IAuthorityMongoSessionAccessor
|
||||
public interface IAuthoritySessionAccessor
|
||||
{
|
||||
IClientSessionHandle? CurrentSession { get; }
|
||||
ValueTask<IClientSessionHandle?> GetSessionAsync(CancellationToken cancellationToken);
|
||||
@@ -19,7 +19,7 @@ public interface IAuthorityMongoSessionAccessor
|
||||
/// <summary>
|
||||
/// In-memory implementation that always returns null session.
|
||||
/// </summary>
|
||||
public sealed class NullAuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||
public sealed class NullAuthoritySessionAccessor : IAuthoritySessionAccessor
|
||||
{
|
||||
public IClientSessionHandle? CurrentSession => null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
namespace StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for bootstrap invites.
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
namespace StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of bootstrap invite store for development/testing.
|
||||
@@ -9,9 +9,9 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
@@ -13,9 +13,9 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Airgap;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
@@ -171,7 +171,7 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture<AuthorityWebApplic
|
||||
var store = new TestAirgapAuditStore();
|
||||
_airgapStore = store;
|
||||
services.Replace(ServiceDescriptor.Singleton<IAuthorityAirgapAuditStore>(store));
|
||||
services.Replace(ServiceDescriptor.Singleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>());
|
||||
services.Replace(ServiceDescriptor.Singleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>());
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Audit;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Audit;
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Identity;
|
||||
|
||||
public class AuthorityIdentityProviderRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RegistryIndexesProvidersAndAggregatesCapabilities()
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Identity;
|
||||
|
||||
public class AuthorityIdentityProviderRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RegistryIndexesProvidersAndAggregatesCapabilities()
|
||||
{
|
||||
var providers = new[]
|
||||
{
|
||||
CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false, supportsBootstrap: true),
|
||||
CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true)
|
||||
};
|
||||
|
||||
using var serviceProvider = BuildServiceProvider(providers);
|
||||
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
|
||||
Assert.Equal(2, registry.Providers.Count);
|
||||
Assert.True(registry.TryGet("standard", out var standard));
|
||||
Assert.Equal("standard", standard!.Name);
|
||||
Assert.Single(registry.PasswordProviders);
|
||||
|
||||
using var serviceProvider = BuildServiceProvider(providers);
|
||||
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
|
||||
Assert.Equal(2, registry.Providers.Count);
|
||||
Assert.True(registry.TryGet("standard", out var standard));
|
||||
Assert.Equal("standard", standard!.Name);
|
||||
Assert.Single(registry.PasswordProviders);
|
||||
Assert.Single(registry.MfaProviders);
|
||||
Assert.Single(registry.ClientProvisioningProviders);
|
||||
Assert.Single(registry.BootstrapProviders);
|
||||
@@ -36,73 +36,73 @@ public class AuthorityIdentityProviderRegistryTests
|
||||
Assert.True(registry.AggregateCapabilities.SupportsMfa);
|
||||
Assert.True(registry.AggregateCapabilities.SupportsClientProvisioning);
|
||||
Assert.True(registry.AggregateCapabilities.SupportsBootstrap);
|
||||
|
||||
await using var handle = await registry.AcquireAsync("standard", default);
|
||||
Assert.Same(providers[0], handle.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegistryIgnoresDuplicateNames()
|
||||
{
|
||||
var duplicate = CreateProvider("standard", "ldap", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false);
|
||||
var providers = new[]
|
||||
{
|
||||
CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false),
|
||||
duplicate
|
||||
};
|
||||
|
||||
using var serviceProvider = BuildServiceProvider(providers);
|
||||
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
|
||||
Assert.Single(registry.Providers);
|
||||
Assert.Equal("standard", registry.Providers.First().Name);
|
||||
Assert.True(registry.TryGet("standard", out var provider));
|
||||
await using var handle = await registry.AcquireAsync("standard", default);
|
||||
Assert.Same(providers[0], handle.Provider);
|
||||
Assert.Equal("standard", provider!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_ReturnsScopedProviderInstances()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().Build();
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"scoped",
|
||||
"scoped",
|
||||
true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: new[] { AuthorityPluginCapabilities.Password },
|
||||
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
ConfigPath: string.Empty);
|
||||
|
||||
var context = new AuthorityPluginContext(manifest, configuration);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IIdentityProviderPlugin>(_ => new ScopedIdentityProviderPlugin(context));
|
||||
|
||||
using var serviceProvider = services.BuildServiceProvider();
|
||||
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
|
||||
await using var first = await registry.AcquireAsync("scoped", default);
|
||||
await using var second = await registry.AcquireAsync("scoped", default);
|
||||
|
||||
var firstPlugin = Assert.IsType<ScopedIdentityProviderPlugin>(first.Provider);
|
||||
var secondPlugin = Assert.IsType<ScopedIdentityProviderPlugin>(second.Provider);
|
||||
Assert.NotEqual(firstPlugin.InstanceId, secondPlugin.InstanceId);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildServiceProvider(IEnumerable<IIdentityProviderPlugin> providers)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
services.AddSingleton<IIdentityProviderPlugin>(provider);
|
||||
}
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
|
||||
await using var handle = await registry.AcquireAsync("standard", default);
|
||||
Assert.Same(providers[0], handle.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegistryIgnoresDuplicateNames()
|
||||
{
|
||||
var duplicate = CreateProvider("standard", "ldap", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false);
|
||||
var providers = new[]
|
||||
{
|
||||
CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false),
|
||||
duplicate
|
||||
};
|
||||
|
||||
using var serviceProvider = BuildServiceProvider(providers);
|
||||
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
|
||||
Assert.Single(registry.Providers);
|
||||
Assert.Equal("standard", registry.Providers.First().Name);
|
||||
Assert.True(registry.TryGet("standard", out var provider));
|
||||
await using var handle = await registry.AcquireAsync("standard", default);
|
||||
Assert.Same(providers[0], handle.Provider);
|
||||
Assert.Equal("standard", provider!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_ReturnsScopedProviderInstances()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().Build();
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"scoped",
|
||||
"scoped",
|
||||
true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: new[] { AuthorityPluginCapabilities.Password },
|
||||
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
ConfigPath: string.Empty);
|
||||
|
||||
var context = new AuthorityPluginContext(manifest, configuration);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IIdentityProviderPlugin>(_ => new ScopedIdentityProviderPlugin(context));
|
||||
|
||||
using var serviceProvider = services.BuildServiceProvider();
|
||||
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
|
||||
await using var first = await registry.AcquireAsync("scoped", default);
|
||||
await using var second = await registry.AcquireAsync("scoped", default);
|
||||
|
||||
var firstPlugin = Assert.IsType<ScopedIdentityProviderPlugin>(first.Provider);
|
||||
var secondPlugin = Assert.IsType<ScopedIdentityProviderPlugin>(second.Provider);
|
||||
Assert.NotEqual(firstPlugin.InstanceId, secondPlugin.InstanceId);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildServiceProvider(IEnumerable<IIdentityProviderPlugin> providers)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
services.AddSingleton<IIdentityProviderPlugin>(provider);
|
||||
}
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static IIdentityProviderPlugin CreateProvider(
|
||||
string name,
|
||||
string type,
|
||||
@@ -131,13 +131,13 @@ public class AuthorityIdentityProviderRegistryTests
|
||||
if (password)
|
||||
{
|
||||
capabilities.Add(AuthorityPluginCapabilities.Password);
|
||||
}
|
||||
|
||||
if (mfa)
|
||||
{
|
||||
capabilities.Add(AuthorityPluginCapabilities.Mfa);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (mfa)
|
||||
{
|
||||
capabilities.Add(AuthorityPluginCapabilities.Mfa);
|
||||
}
|
||||
|
||||
if (clientProvisioning)
|
||||
{
|
||||
capabilities.Add(AuthorityPluginCapabilities.ClientProvisioning);
|
||||
@@ -167,27 +167,27 @@ public class AuthorityIdentityProviderRegistryTests
|
||||
SupportsClientProvisioning: supportsClientProvisioning,
|
||||
SupportsBootstrap: supportsBootstrap);
|
||||
}
|
||||
|
||||
public string Name => Context.Manifest.Name;
|
||||
|
||||
public string Type => Context.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context { get; }
|
||||
|
||||
public IUserCredentialStore Credentials => throw new NotImplementedException();
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => throw new NotImplementedException();
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
|
||||
private sealed class ScopedIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
|
||||
public string Name => Context.Manifest.Name;
|
||||
|
||||
public string Type => Context.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context { get; }
|
||||
|
||||
public IUserCredentialStore Credentials => throw new NotImplementedException();
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => throw new NotImplementedException();
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
|
||||
private sealed class ScopedIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
public ScopedIdentityProviderPlugin(AuthorityPluginContext context)
|
||||
{
|
||||
Context = context;
|
||||
@@ -198,24 +198,24 @@ public class AuthorityIdentityProviderRegistryTests
|
||||
SupportsClientProvisioning: false,
|
||||
SupportsBootstrap: false);
|
||||
}
|
||||
|
||||
public Guid InstanceId { get; }
|
||||
|
||||
public string Name => Context.Manifest.Name;
|
||||
|
||||
public string Type => Context.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context { get; }
|
||||
|
||||
public IUserCredentialStore Credentials => throw new NotImplementedException();
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => throw new NotImplementedException();
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
}
|
||||
|
||||
public Guid InstanceId { get; }
|
||||
|
||||
public string Name => Context.Manifest.Name;
|
||||
|
||||
public string Type => Context.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context { get; }
|
||||
|
||||
public IUserCredentialStore Credentials => throw new NotImplementedException();
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => throw new NotImplementedException();
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenIddict.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Identity;
|
||||
|
||||
public class AuthorityIdentityProviderSelectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_UsesSingleProviderWhenNoParameter()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: new[] { CreateProvider("standard", supportsPassword: true) });
|
||||
var request = new OpenIddictRequest();
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("standard", result.Provider!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_FailsWhenNoProviders()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: Array.Empty<IIdentityProviderPlugin>());
|
||||
var request = new OpenIddictRequest();
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(OpenIddictConstants.Errors.UnsupportedGrantType, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_RequiresParameterWhenMultipleProviders()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: new[]
|
||||
{
|
||||
CreateProvider("standard", supportsPassword: true),
|
||||
CreateProvider("ldap", supportsPassword: true)
|
||||
});
|
||||
var request = new OpenIddictRequest();
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_HonoursProviderParameter()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: new[]
|
||||
{
|
||||
CreateProvider("standard", supportsPassword: true),
|
||||
CreateProvider("ldap", supportsPassword: true)
|
||||
});
|
||||
var request = new OpenIddictRequest();
|
||||
request.SetParameter(AuthorityOpenIddictConstants.ProviderParameterName, "ldap");
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("ldap", result.Provider!.Name);
|
||||
}
|
||||
|
||||
private static AuthorityIdentityProviderRegistry CreateRegistry(IEnumerable<IIdentityProviderPlugin> passwordProviders)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
foreach (var provider in passwordProviders)
|
||||
{
|
||||
services.AddSingleton<IIdentityProviderPlugin>(provider);
|
||||
}
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
return new AuthorityIdentityProviderRegistry(serviceProvider, Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
}
|
||||
|
||||
private static IIdentityProviderPlugin CreateProvider(string name, bool supportsPassword)
|
||||
{
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
name,
|
||||
"standard",
|
||||
true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: supportsPassword ? new[] { AuthorityPluginCapabilities.Password } : Array.Empty<string>(),
|
||||
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
ConfigPath: string.Empty);
|
||||
|
||||
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
return new SelectorTestProvider(context, supportsPassword);
|
||||
}
|
||||
|
||||
private sealed class SelectorTestProvider : IIdentityProviderPlugin
|
||||
{
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenIddict.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Identity;
|
||||
|
||||
public class AuthorityIdentityProviderSelectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_UsesSingleProviderWhenNoParameter()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: new[] { CreateProvider("standard", supportsPassword: true) });
|
||||
var request = new OpenIddictRequest();
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("standard", result.Provider!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_FailsWhenNoProviders()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: Array.Empty<IIdentityProviderPlugin>());
|
||||
var request = new OpenIddictRequest();
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(OpenIddictConstants.Errors.UnsupportedGrantType, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_RequiresParameterWhenMultipleProviders()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: new[]
|
||||
{
|
||||
CreateProvider("standard", supportsPassword: true),
|
||||
CreateProvider("ldap", supportsPassword: true)
|
||||
});
|
||||
var request = new OpenIddictRequest();
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePasswordProvider_HonoursProviderParameter()
|
||||
{
|
||||
var registry = CreateRegistry(passwordProviders: new[]
|
||||
{
|
||||
CreateProvider("standard", supportsPassword: true),
|
||||
CreateProvider("ldap", supportsPassword: true)
|
||||
});
|
||||
var request = new OpenIddictRequest();
|
||||
request.SetParameter(AuthorityOpenIddictConstants.ProviderParameterName, "ldap");
|
||||
|
||||
var result = AuthorityIdentityProviderSelector.ResolvePasswordProvider(request, registry);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("ldap", result.Provider!.Name);
|
||||
}
|
||||
|
||||
private static AuthorityIdentityProviderRegistry CreateRegistry(IEnumerable<IIdentityProviderPlugin> passwordProviders)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
foreach (var provider in passwordProviders)
|
||||
{
|
||||
services.AddSingleton<IIdentityProviderPlugin>(provider);
|
||||
}
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
return new AuthorityIdentityProviderRegistry(serviceProvider, Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
}
|
||||
|
||||
private static IIdentityProviderPlugin CreateProvider(string name, bool supportsPassword)
|
||||
{
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
name,
|
||||
"standard",
|
||||
true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: supportsPassword ? new[] { AuthorityPluginCapabilities.Password } : Array.Empty<string>(),
|
||||
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
ConfigPath: string.Empty);
|
||||
|
||||
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
return new SelectorTestProvider(context, supportsPassword);
|
||||
}
|
||||
|
||||
private sealed class SelectorTestProvider : IIdentityProviderPlugin
|
||||
{
|
||||
public SelectorTestProvider(AuthorityPluginContext context, bool supportsPassword)
|
||||
{
|
||||
Context = context;
|
||||
@@ -105,22 +105,22 @@ public class AuthorityIdentityProviderSelectorTests
|
||||
SupportsClientProvisioning: false,
|
||||
SupportsBootstrap: false);
|
||||
}
|
||||
|
||||
public string Name => Context.Manifest.Name;
|
||||
|
||||
public string Type => Context.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context { get; }
|
||||
|
||||
public IUserCredentialStore Credentials => throw new NotImplementedException();
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => throw new NotImplementedException();
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
}
|
||||
|
||||
public string Name => Context.Manifest.Name;
|
||||
|
||||
public string Type => Context.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context { get; }
|
||||
|
||||
public IUserCredentialStore Credentials => throw new NotImplementedException();
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => throw new NotImplementedException();
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Extensions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
@@ -105,7 +105,7 @@ public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Progr
|
||||
services.RemoveAll<IAuthorityRefreshTokenStore>();
|
||||
services.RemoveAll<IAuthorityAirgapAuditStore>();
|
||||
services.RemoveAll<IAuthorityRevocationExportStateStore>();
|
||||
services.RemoveAll<IAuthorityMongoSessionAccessor>();
|
||||
services.RemoveAll<IAuthoritySessionAccessor>();
|
||||
|
||||
services.AddAuthorityMongoStorage(options =>
|
||||
{
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class EnvironmentVariableScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> originals = new(StringComparer.Ordinal);
|
||||
private bool disposed;
|
||||
|
||||
public EnvironmentVariableScope(IEnumerable<KeyValuePair<string, string?>> overrides)
|
||||
{
|
||||
if (overrides is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(overrides));
|
||||
}
|
||||
|
||||
foreach (var kvp in overrides)
|
||||
{
|
||||
if (originals.ContainsKey(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
originals.Add(kvp.Key, Environment.GetEnvironmentVariable(kvp.Key));
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in originals)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class EnvironmentVariableScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> originals = new(StringComparer.Ordinal);
|
||||
private bool disposed;
|
||||
|
||||
public EnvironmentVariableScope(IEnumerable<KeyValuePair<string, string?>> overrides)
|
||||
{
|
||||
if (overrides is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(overrides));
|
||||
}
|
||||
|
||||
foreach (var kvp in overrides)
|
||||
{
|
||||
if (originals.ContainsKey(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
originals.Add(kvp.Key, Environment.GetEnvironmentVariable(kvp.Key));
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in originals)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "TestAuth";
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var tenantHeader = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValues)
|
||||
? tenantValues.ToString()
|
||||
: "tenant-default";
|
||||
|
||||
var scopesHeader = Request.Headers.TryGetValue("X-Test-Scopes", out var scopeValues)
|
||||
? scopeValues.ToString()
|
||||
: StellaOpsScopes.AdvisoryAiOperate;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.ClientId, "test-client")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader) &&
|
||||
!string.Equals(tenantHeader, "none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantHeader.Trim()));
|
||||
}
|
||||
|
||||
var scopes = scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "TestAuth";
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var tenantHeader = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValues)
|
||||
? tenantValues.ToString()
|
||||
: "tenant-default";
|
||||
|
||||
var scopesHeader = Request.Headers.TryGetValue("X-Test-Scopes", out var scopeValues)
|
||||
? scopeValues.ToString()
|
||||
: StellaOpsScopes.AdvisoryAiOperate;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.ClientId, "test-client")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader) &&
|
||||
!string.Equals(tenantHeader, "none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantHeader.Trim()));
|
||||
}
|
||||
|
||||
var scopes = scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,259 +1,259 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Notifications;
|
||||
|
||||
public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public NotifyAckTokenRotationEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsOk_AndEmitsAuditEvent()
|
||||
{
|
||||
const string AckEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
const string AckActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ACTIVEKEYID";
|
||||
const string AckKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYPATH";
|
||||
const string AckKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYSOURCE";
|
||||
const string AckAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ALGORITHM";
|
||||
const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
const string WebhooksAllowedHost0Key = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ALLOWEDHOSTS__0";
|
||||
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-success");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(AckEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(AckActiveKeyIdKey, "ack-key-1"),
|
||||
new KeyValuePair<string, string?>(AckKeyPathKey, key1Path),
|
||||
new KeyValuePair<string, string?>(AckKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(AckAlgorithmKey, SignatureAlgorithms.Es256),
|
||||
new KeyValuePair<string, string?>(WebhooksEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(WebhooksAllowedHost0Key, "hooks.slack.com")
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z"));
|
||||
|
||||
using var scopedFactory = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com",
|
||||
["Authority:Notifications:Escalation:Scope"] = "notify.escalate",
|
||||
["Authority:Notifications:Escalation:RequireAdminScope"] = "true"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = scopedFactory.CreateClient();
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
keyId = "ack-key-2",
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AckRotateResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("ack-key-2", payload!.ActiveKeyId);
|
||||
Assert.Equal("ack-key-1", payload.PreviousKeyId);
|
||||
|
||||
var rotationEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotated");
|
||||
Assert.Equal(AuthEventOutcome.Success, rotationEvent.Outcome);
|
||||
Assert.Contains(rotationEvent.Properties, property =>
|
||||
string.Equals(property.Name, "notify.ack.key_id", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "ack-key-2", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-failure");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T13:00:00Z"));
|
||||
|
||||
using var app = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var failureEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotation_failed");
|
||||
Assert.Equal(AuthEventOutcome.Failure, failureEvent.Outcome);
|
||||
Assert.Contains("keyId", failureEvent.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures in tests.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AckRotateResponse(
|
||||
string ActiveKeyId,
|
||||
string? Provider,
|
||||
string? Source,
|
||||
string? Location,
|
||||
string? PreviousKeyId,
|
||||
IReadOnlyCollection<string> RetiredKeyIds);
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Notifications;
|
||||
|
||||
public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public NotifyAckTokenRotationEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsOk_AndEmitsAuditEvent()
|
||||
{
|
||||
const string AckEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
const string AckActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ACTIVEKEYID";
|
||||
const string AckKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYPATH";
|
||||
const string AckKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYSOURCE";
|
||||
const string AckAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ALGORITHM";
|
||||
const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
const string WebhooksAllowedHost0Key = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ALLOWEDHOSTS__0";
|
||||
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-success");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(AckEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(AckActiveKeyIdKey, "ack-key-1"),
|
||||
new KeyValuePair<string, string?>(AckKeyPathKey, key1Path),
|
||||
new KeyValuePair<string, string?>(AckKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(AckAlgorithmKey, SignatureAlgorithms.Es256),
|
||||
new KeyValuePair<string, string?>(WebhooksEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(WebhooksAllowedHost0Key, "hooks.slack.com")
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z"));
|
||||
|
||||
using var scopedFactory = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com",
|
||||
["Authority:Notifications:Escalation:Scope"] = "notify.escalate",
|
||||
["Authority:Notifications:Escalation:RequireAdminScope"] = "true"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = scopedFactory.CreateClient();
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
keyId = "ack-key-2",
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AckRotateResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("ack-key-2", payload!.ActiveKeyId);
|
||||
Assert.Equal("ack-key-1", payload.PreviousKeyId);
|
||||
|
||||
var rotationEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotated");
|
||||
Assert.Equal(AuthEventOutcome.Success, rotationEvent.Outcome);
|
||||
Assert.Contains(rotationEvent.Properties, property =>
|
||||
string.Equals(property.Name, "notify.ack.key_id", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "ack-key-2", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-failure");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T13:00:00Z"));
|
||||
|
||||
using var app = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var failureEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotation_failed");
|
||||
Assert.Equal(AuthEventOutcome.Failure, failureEvent.Outcome);
|
||||
Assert.Contains("keyId", failureEvent.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures in tests.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AckRotateResponse(
|
||||
string ActiveKeyId,
|
||||
string? Provider,
|
||||
string? Source,
|
||||
string? Location,
|
||||
string? PreviousKeyId,
|
||||
IReadOnlyCollection<string> RetiredKeyIds);
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenApi;
|
||||
|
||||
public sealed class OpenApiDiscoveryEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public OpenApiDiscoveryEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsJsonSpecificationByDefault()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenApi;
|
||||
|
||||
public sealed class OpenApiDiscoveryEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public OpenApiDiscoveryEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsJsonSpecificationByDefault()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/.well-known/openapi");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.ETag);
|
||||
Assert.Equal("public, max-age=300", response.Headers.CacheControl?.ToString());
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||
Assert.Equal("application/openapi+json; charset=utf-8", contentType);
|
||||
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.ETag);
|
||||
Assert.Equal("public, max-age=300", response.Headers.CacheControl?.ToString());
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||
Assert.Equal("application/openapi+json; charset=utf-8", contentType);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
Assert.Equal("3.1.0", document.RootElement.GetProperty("openapi").GetString());
|
||||
|
||||
var info = document.RootElement.GetProperty("info");
|
||||
Assert.Equal("authority", info.GetProperty("x-stella-service").GetString());
|
||||
Assert.True(info.TryGetProperty("x-stella-grant-types", out var grantsNode));
|
||||
Assert.Contains("authorization_code", grantsNode.EnumerateArray().Select(element => element.GetString()));
|
||||
|
||||
var grantsHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Grants"));
|
||||
Assert.Contains("authorization_code", grantsHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
Assert.Equal("3.1.0", document.RootElement.GetProperty("openapi").GetString());
|
||||
|
||||
var info = document.RootElement.GetProperty("info");
|
||||
Assert.Equal("authority", info.GetProperty("x-stella-service").GetString());
|
||||
Assert.True(info.TryGetProperty("x-stella-grant-types", out var grantsNode));
|
||||
Assert.Contains("authorization_code", grantsNode.EnumerateArray().Select(element => element.GetString()));
|
||||
|
||||
var grantsHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Grants"));
|
||||
Assert.Contains("authorization_code", grantsHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
var scopesHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Scopes"));
|
||||
Assert.Contains("policy:read", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
Assert.Contains("advisory-ai:view", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
Assert.Contains("airgap:status:read", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsYamlWhenRequested()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/openapi+yaml"));
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsYamlWhenRequested()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/openapi+yaml"));
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/openapi+yaml; charset=utf-8", response.Content.Headers.ContentType?.ToString());
|
||||
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/openapi+yaml; charset=utf-8", response.Content.Headers.ContentType?.ToString());
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
Assert.StartsWith("openapi: 3.1.0", payload.TrimStart(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsNotModifiedWhenEtagMatches()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
Assert.StartsWith("openapi: 3.1.0", payload.TrimStart(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsNotModifiedWhenEtagMatches()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var initialResponse = await client.GetAsync("/.well-known/openapi");
|
||||
var etag = initialResponse.Headers.ETag;
|
||||
Assert.NotNull(etag);
|
||||
|
||||
using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
|
||||
conditionalRequest.Headers.IfNoneMatch.Add(etag!);
|
||||
|
||||
var etag = initialResponse.Headers.ETag;
|
||||
Assert.NotNull(etag);
|
||||
|
||||
using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
|
||||
conditionalRequest.Headers.IfNoneMatch.Add(etag!);
|
||||
|
||||
using var conditionalResponse = await client.SendAsync(conditionalRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, conditionalResponse.StatusCode);
|
||||
Assert.Equal(etag!.Tag, conditionalResponse.Headers.ETag?.Tag);
|
||||
Assert.Equal("public, max-age=300", conditionalResponse.Headers.CacheControl?.ToString());
|
||||
Assert.True(conditionalResponse.Content.Headers.ContentLength == 0 || conditionalResponse.Content.Headers.ContentLength is null);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, conditionalResponse.StatusCode);
|
||||
Assert.Equal(etag!.Tag, conditionalResponse.Headers.ETag?.Tag);
|
||||
Assert.Equal("public, max-age=300", conditionalResponse.Headers.CacheControl?.ToString());
|
||||
Assert.True(conditionalResponse.Content.Headers.ContentLength == 0 || conditionalResponse.Content.Headers.ContentLength is null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ using StellaOps.Authority.Airgap;
|
||||
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.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
@@ -4475,7 +4475,7 @@ internal sealed class StubCertificateValidator : IAuthorityClientCertificateVali
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
|
||||
internal sealed class NullMongoSessionAccessor : IAuthoritySessionAccessor
|
||||
{
|
||||
public IClientSessionHandle? CurrentSession => null;
|
||||
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class DiscoveryMetadataTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public DiscoveryMetadataTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenIdDiscovery_IncludesAdvisoryAiMetadata()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/.well-known/openid-configuration");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_scopes_supported", out var scopesNode));
|
||||
|
||||
var scopes = scopesNode.EnumerateArray().Select(element => element.GetString()).ToArray();
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiView, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiOperate, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiAdmin, scopes);
|
||||
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_remote_inference", out var remoteNode));
|
||||
Assert.False(remoteNode.GetProperty("enabled").GetBoolean());
|
||||
Assert.True(remoteNode.GetProperty("require_tenant_consent").GetBoolean());
|
||||
|
||||
var profiles = remoteNode.GetProperty("allowed_profiles").EnumerateArray().ToArray();
|
||||
Assert.Empty(profiles);
|
||||
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class DiscoveryMetadataTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public DiscoveryMetadataTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenIdDiscovery_IncludesAdvisoryAiMetadata()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/.well-known/openid-configuration");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_scopes_supported", out var scopesNode));
|
||||
|
||||
var scopes = scopesNode.EnumerateArray().Select(element => element.GetString()).ToArray();
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiView, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiOperate, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiAdmin, scopes);
|
||||
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_remote_inference", out var remoteNode));
|
||||
Assert.False(remoteNode.GetProperty("enabled").GetBoolean());
|
||||
Assert.True(remoteNode.GetProperty("require_tenant_consent").GetBoolean());
|
||||
|
||||
var profiles = remoteNode.GetProperty("allowed_profiles").EnumerateArray().ToArray();
|
||||
Assert.Empty(profiles);
|
||||
|
||||
Assert.True(root.TryGetProperty("stellaops_airgap_scopes_supported", out var airgapNode));
|
||||
var airgapScopes = airgapNode.EnumerateArray().Select(element => element.GetString()).ToArray();
|
||||
Assert.Contains(StellaOpsScopes.AirgapSeal, airgapScopes);
|
||||
@@ -61,10 +61,10 @@ public sealed class DiscoveryMetadataTests : IClassFixture<AuthorityWebApplicati
|
||||
Assert.Contains(StellaOpsScopes.ObservabilityRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.TimelineRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.TimelineWrite, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceCreate, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceHold, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.AttestRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.ObservabilityIncident, observabilityScopes);
|
||||
}
|
||||
}
|
||||
Assert.Contains(StellaOpsScopes.EvidenceCreate, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceHold, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.AttestRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.ObservabilityIncident, observabilityScopes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class LegacyAuthDeprecationTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private static readonly string ExpectedDeprecationHeader = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetHeader = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetIso = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public LegacyAuthDeprecationTests(AuthorityWebApplicationFactory factory)
|
||||
=> this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_IncludesDeprecationHeaders()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues));
|
||||
Assert.Contains(ExpectedDeprecationHeader, deprecationValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues));
|
||||
Assert.Contains(ExpectedSunsetHeader, sunsetValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Warning", out var warningValues));
|
||||
Assert.Contains(warningValues, warning => warning.Contains("Legacy Authority endpoint", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Link", out var linkValues));
|
||||
Assert.Contains(linkValues, value => value.Contains("rel=\"sunset\"", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_EmitsAuditEvent()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
|
||||
using var customFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
});
|
||||
});
|
||||
|
||||
using var client = customFactory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
|
||||
var record = Assert.Single(sink.Events);
|
||||
Assert.Equal("authority.api.legacy_endpoint", record.EventType);
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.original", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/oauth/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.canonical", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.sunset_at", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, ExpectedSunsetIso, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class LegacyAuthDeprecationTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private static readonly string ExpectedDeprecationHeader = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetHeader = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetIso = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public LegacyAuthDeprecationTests(AuthorityWebApplicationFactory factory)
|
||||
=> this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_IncludesDeprecationHeaders()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues));
|
||||
Assert.Contains(ExpectedDeprecationHeader, deprecationValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues));
|
||||
Assert.Contains(ExpectedSunsetHeader, sunsetValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Warning", out var warningValues));
|
||||
Assert.Contains(warningValues, warning => warning.Contains("Legacy Authority endpoint", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Link", out var linkValues));
|
||||
Assert.Contains(linkValues, value => value.Contains("rel=\"sunset\"", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_EmitsAuditEvent()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
|
||||
using var customFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
});
|
||||
});
|
||||
|
||||
using var client = customFactory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
|
||||
var record = Assert.Single(sink.Events);
|
||||
Assert.Equal("authority.api.legacy_endpoint", record.EventType);
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.original", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/oauth/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.canonical", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.sunset_at", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, ExpectedSunsetIso, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,9 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.OpenIddict.Handlers;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
@@ -22,7 +22,7 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero);
|
||||
var clock = new FakeTimeProvider(issuedAt);
|
||||
var tokenStore = new InMemoryTokenStore();
|
||||
var handler = new PersistTokensHandler(tokenStore, new NullAuthorityMongoSessionAccessor(), clock, Activity, NullLogger<PersistTokensHandler>.Instance);
|
||||
var handler = new PersistTokensHandler(tokenStore, new NullAuthoritySessionAccessor(), clock, Activity, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var identity = new ClaimsIdentity(authenticationType: "test");
|
||||
identity.SetClaim(OpenIddictConstants.Claims.Subject, "subject-1");
|
||||
|
||||
@@ -1,193 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Plugins;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Plugins;
|
||||
|
||||
public class AuthorityPluginLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterPlugins_ReturnsEmptySummary_WhenNoPluginsConfigured()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder().Build();
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
configuration,
|
||||
Array.Empty<AuthorityPluginContext>(),
|
||||
Array.Empty<AuthorityPluginLoader.LoadedPluginDescriptor>(),
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Empty(summary.RegisteredPlugins);
|
||||
Assert.Empty(summary.Failures);
|
||||
Assert.Empty(summary.MissingOrderedPlugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterPlugins_RecordsFailure_WhenAssemblyMissing()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var hostConfiguration = new ConfigurationBuilder().Build();
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
"StellaOps.Authority.Plugin.Standard",
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
|
||||
var contexts = new[]
|
||||
{
|
||||
new AuthorityPluginContext(manifest, hostConfiguration)
|
||||
};
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
hostConfiguration,
|
||||
contexts,
|
||||
Array.Empty<AuthorityPluginLoader.LoadedPluginDescriptor>(),
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
var failure = Assert.Single(summary.Failures);
|
||||
Assert.Equal("standard", failure.PluginName);
|
||||
Assert.Contains("Assembly", failure.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterPlugins_RegistersEnabledPlugin_WhenRegistrarAvailable()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
var hostConfiguration = new ConfigurationBuilder().Build();
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"test",
|
||||
TestAuthorityPluginRegistrar.PluginTypeIdentifier,
|
||||
true,
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly.GetName().Name,
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly.Location,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>(),
|
||||
"test.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, hostConfiguration);
|
||||
var descriptor = new AuthorityPluginLoader.LoadedPluginDescriptor(
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly,
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly.Location);
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
hostConfiguration,
|
||||
new[] { pluginContext },
|
||||
new[] { descriptor },
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Contains("test", summary.RegisteredPlugins);
|
||||
Assert.Empty(summary.Failures);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
Assert.NotNull(provider.GetRequiredService<TestMarkerService>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterPlugins_ActivatesRegistrarUsingDependencyInjection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var hostConfiguration = new ConfigurationBuilder().Build();
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"di-test",
|
||||
DiAuthorityPluginRegistrar.PluginTypeIdentifier,
|
||||
true,
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly.GetName().Name,
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly.Location,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>(),
|
||||
"di-test.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, hostConfiguration);
|
||||
var descriptor = new AuthorityPluginLoader.LoadedPluginDescriptor(
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly,
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly.Location);
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
hostConfiguration,
|
||||
new[] { pluginContext },
|
||||
new[] { descriptor },
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Contains("di-test", summary.RegisteredPlugins);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var dependent = provider.GetRequiredService<DependentService>();
|
||||
Assert.True(dependent.LoggerWasResolved);
|
||||
Assert.True(dependent.TimeProviderResolved);
|
||||
}
|
||||
|
||||
private sealed class TestAuthorityPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
public const string PluginTypeIdentifier = "test-plugin";
|
||||
|
||||
public string PluginType => PluginTypeIdentifier;
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
context.Services.AddSingleton<TestMarkerService>();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestMarkerService
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class DiAuthorityPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
public const string PluginTypeIdentifier = "test-plugin-di";
|
||||
|
||||
private readonly ILogger<DiAuthorityPluginRegistrar> logger;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public DiAuthorityPluginRegistrar(ILogger<DiAuthorityPluginRegistrar> logger, TimeProvider timeProvider)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public string PluginType => PluginTypeIdentifier;
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
context.Services.AddSingleton(new DependentService(logger != null, timeProvider != null));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DependentService
|
||||
{
|
||||
public DependentService(bool loggerResolved, bool timeProviderResolved)
|
||||
{
|
||||
LoggerWasResolved = loggerResolved;
|
||||
TimeProviderResolved = timeProviderResolved;
|
||||
}
|
||||
|
||||
public bool LoggerWasResolved { get; }
|
||||
|
||||
public bool TimeProviderResolved { get; }
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Plugins;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Plugins;
|
||||
|
||||
public class AuthorityPluginLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterPlugins_ReturnsEmptySummary_WhenNoPluginsConfigured()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder().Build();
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
configuration,
|
||||
Array.Empty<AuthorityPluginContext>(),
|
||||
Array.Empty<AuthorityPluginLoader.LoadedPluginDescriptor>(),
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Empty(summary.RegisteredPlugins);
|
||||
Assert.Empty(summary.Failures);
|
||||
Assert.Empty(summary.MissingOrderedPlugins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterPlugins_RecordsFailure_WhenAssemblyMissing()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var hostConfiguration = new ConfigurationBuilder().Build();
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
"StellaOps.Authority.Plugin.Standard",
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
|
||||
var contexts = new[]
|
||||
{
|
||||
new AuthorityPluginContext(manifest, hostConfiguration)
|
||||
};
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
hostConfiguration,
|
||||
contexts,
|
||||
Array.Empty<AuthorityPluginLoader.LoadedPluginDescriptor>(),
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
var failure = Assert.Single(summary.Failures);
|
||||
Assert.Equal("standard", failure.PluginName);
|
||||
Assert.Contains("Assembly", failure.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterPlugins_RegistersEnabledPlugin_WhenRegistrarAvailable()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
var hostConfiguration = new ConfigurationBuilder().Build();
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"test",
|
||||
TestAuthorityPluginRegistrar.PluginTypeIdentifier,
|
||||
true,
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly.GetName().Name,
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly.Location,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>(),
|
||||
"test.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, hostConfiguration);
|
||||
var descriptor = new AuthorityPluginLoader.LoadedPluginDescriptor(
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly,
|
||||
typeof(TestAuthorityPluginRegistrar).Assembly.Location);
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
hostConfiguration,
|
||||
new[] { pluginContext },
|
||||
new[] { descriptor },
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Contains("test", summary.RegisteredPlugins);
|
||||
Assert.Empty(summary.Failures);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
Assert.NotNull(provider.GetRequiredService<TestMarkerService>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterPlugins_ActivatesRegistrarUsingDependencyInjection()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var hostConfiguration = new ConfigurationBuilder().Build();
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"di-test",
|
||||
DiAuthorityPluginRegistrar.PluginTypeIdentifier,
|
||||
true,
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly.GetName().Name,
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly.Location,
|
||||
Array.Empty<string>(),
|
||||
new Dictionary<string, string?>(),
|
||||
"di-test.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, hostConfiguration);
|
||||
var descriptor = new AuthorityPluginLoader.LoadedPluginDescriptor(
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly,
|
||||
typeof(DiAuthorityPluginRegistrar).Assembly.Location);
|
||||
|
||||
var summary = AuthorityPluginLoader.RegisterPluginsCore(
|
||||
services,
|
||||
hostConfiguration,
|
||||
new[] { pluginContext },
|
||||
new[] { descriptor },
|
||||
Array.Empty<string>(),
|
||||
NullLogger.Instance);
|
||||
|
||||
Assert.Contains("di-test", summary.RegisteredPlugins);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var dependent = provider.GetRequiredService<DependentService>();
|
||||
Assert.True(dependent.LoggerWasResolved);
|
||||
Assert.True(dependent.TimeProviderResolved);
|
||||
}
|
||||
|
||||
private sealed class TestAuthorityPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
public const string PluginTypeIdentifier = "test-plugin";
|
||||
|
||||
public string PluginType => PluginTypeIdentifier;
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
context.Services.AddSingleton<TestMarkerService>();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestMarkerService
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class DiAuthorityPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
public const string PluginTypeIdentifier = "test-plugin-di";
|
||||
|
||||
private readonly ILogger<DiAuthorityPluginRegistrar> logger;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public DiAuthorityPluginRegistrar(ILogger<DiAuthorityPluginRegistrar> logger, TimeProvider timeProvider)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public string PluginType => PluginTypeIdentifier;
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
context.Services.AddSingleton(new DependentService(logger != null, timeProvider != null));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DependentService
|
||||
{
|
||||
public DependentService(bool loggerResolved, bool timeProviderResolved)
|
||||
{
|
||||
LoggerWasResolved = loggerResolved;
|
||||
TimeProviderResolved = timeProviderResolved;
|
||||
}
|
||||
|
||||
public bool LoggerWasResolved { get; }
|
||||
|
||||
public bool TimeProviderResolved { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -13,85 +13,85 @@ using Microsoft.AspNetCore.Hosting;
|
||||
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);
|
||||
|
||||
|
||||
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 hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
@@ -138,11 +138,11 @@ public class AuthorityRateLimiterIntegrationTests
|
||||
var host = hostBuilder.Start();
|
||||
return host.GetTestServer() ?? throw new InvalidOperationException("Failed to create TestServer.");
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent CreateTokenForm(string clientId)
|
||||
=> new(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = clientId
|
||||
});
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent CreateTokenForm(string clientId)
|
||||
=> new(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = clientId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.RateLimiting;
|
||||
|
||||
public class AuthorityRateLimiterMetadataAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetClientId_UpdatesFeatureMetadata()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
var feature = new AuthorityRateLimiterFeature(new AuthorityRateLimiterMetadata());
|
||||
context.Features.Set<IAuthorityRateLimiterFeature>(feature);
|
||||
|
||||
var accessor = new AuthorityRateLimiterMetadataAccessor(new HttpContextAccessor { HttpContext = context });
|
||||
|
||||
accessor.SetClientId("client-123");
|
||||
accessor.SetTag("custom", "tag");
|
||||
accessor.SetSubjectId("subject-1");
|
||||
accessor.SetTenant("Tenant-Alpha");
|
||||
accessor.SetProject("Project-Beta");
|
||||
|
||||
var metadata = accessor.GetMetadata();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("client-123", metadata!.ClientId);
|
||||
Assert.Equal("subject-1", metadata.SubjectId);
|
||||
Assert.Equal("client-123", metadata.Tags["authority.client_id"]);
|
||||
Assert.Equal("subject-1", metadata.Tags["authority.subject_id"]);
|
||||
Assert.Equal("tenant-alpha", metadata.Tenant);
|
||||
Assert.Equal("tenant-alpha", metadata.Tags["authority.tenant"]);
|
||||
Assert.Equal("project-beta", metadata.Project);
|
||||
Assert.Equal("project-beta", metadata.Tags["authority.project"]);
|
||||
Assert.Equal("tag", metadata.Tags["custom"]);
|
||||
}
|
||||
}
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.RateLimiting;
|
||||
|
||||
public class AuthorityRateLimiterMetadataAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetClientId_UpdatesFeatureMetadata()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
var feature = new AuthorityRateLimiterFeature(new AuthorityRateLimiterMetadata());
|
||||
context.Features.Set<IAuthorityRateLimiterFeature>(feature);
|
||||
|
||||
var accessor = new AuthorityRateLimiterMetadataAccessor(new HttpContextAccessor { HttpContext = context });
|
||||
|
||||
accessor.SetClientId("client-123");
|
||||
accessor.SetTag("custom", "tag");
|
||||
accessor.SetSubjectId("subject-1");
|
||||
accessor.SetTenant("Tenant-Alpha");
|
||||
accessor.SetProject("Project-Beta");
|
||||
|
||||
var metadata = accessor.GetMetadata();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("client-123", metadata!.ClientId);
|
||||
Assert.Equal("subject-1", metadata.SubjectId);
|
||||
Assert.Equal("client-123", metadata.Tags["authority.client_id"]);
|
||||
Assert.Equal("subject-1", metadata.Tags["authority.subject_id"]);
|
||||
Assert.Equal("tenant-alpha", metadata.Tenant);
|
||||
Assert.Equal("tenant-alpha", metadata.Tags["authority.tenant"]);
|
||||
Assert.Equal("project-beta", metadata.Project);
|
||||
Assert.Equal("project-beta", metadata.Tags["authority.project"]);
|
||||
Assert.Equal("tag", metadata.Tags["custom"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Testing;
|
||||
|
||||
internal static class TestEnvironment
|
||||
{
|
||||
[ModuleInitializer]
|
||||
public static void Initialize()
|
||||
{
|
||||
OpenSslLegacyShim.EnsureOpenSsl11();
|
||||
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ISSUER", "https://authority.test");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_STORAGE__CONNECTIONSTRING", "mongodb://localhost/authority");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SIGNING__ENABLED", "false");
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Testing;
|
||||
|
||||
internal static class TestEnvironment
|
||||
{
|
||||
[ModuleInitializer]
|
||||
public static void Initialize()
|
||||
{
|
||||
OpenSslLegacyShim.EnsureOpenSsl11();
|
||||
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ISSUER", "https://authority.test");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_STORAGE__CONNECTIONSTRING", "mongodb://localhost/authority");
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SIGNING__ENABLED", "false");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,457 +1,457 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Authority.Vulnerability.Attachments;
|
||||
using StellaOps.Authority.Vulnerability.Workflow;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Vulnerability;
|
||||
|
||||
public sealed class VulnWorkflowTokenEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string SigningActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ACTIVEKEYID";
|
||||
private const string SigningKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYPATH";
|
||||
private const string SigningKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYSOURCE";
|
||||
private const string SigningAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ALGORITHM";
|
||||
|
||||
public VulnWorkflowTokenEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign", "comment" },
|
||||
context = new Dictionary<string, string> { ["finding_id"] = "F-123" },
|
||||
nonce = "workflow-nonce-123456",
|
||||
expiresInSeconds = 600
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
var issueBody = await issueResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}");
|
||||
|
||||
var issued = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryIssueResponse>(
|
||||
issueBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("workflow-nonce-123456", issued!.Nonce);
|
||||
Assert.Contains("assign", issued.Actions);
|
||||
Assert.Contains("comment", issued.Actions);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
RequiredAction = "assign",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-123456"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
var verifyBody = await verifyResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}");
|
||||
|
||||
var verified = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryVerifyResponse>(
|
||||
verifyBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("tenant-default", verified!.Tenant);
|
||||
Assert.Equal("workflow-nonce-123456", verified.Nonce);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.workflow.actor");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = Array.Empty<string>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var error = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_request", error!["error"]);
|
||||
Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:20:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign" },
|
||||
nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnWorkflowAntiForgeryIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
RequiredAction = "close",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123",
|
||||
FindingId = "find-456",
|
||||
ContentHash = "sha256:abc123",
|
||||
ContentType = "application/pdf",
|
||||
Metadata = new Dictionary<string, string?> { ["origin"] = "vuln-workflow" }
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("attach-123", issued!.AttachmentId);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
|
||||
var verified = await verifyResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenVerifyResponse>();
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("ledger-hash-001", verified!.LedgerEventHash);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-999",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateSignedAuthorityApp(
|
||||
RecordingAuthEventSink sink,
|
||||
FakeTimeProvider timeProvider,
|
||||
string signingKeyId,
|
||||
string signingKeyPath)
|
||||
{
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = signingKeyId,
|
||||
["Authority:Signing:KeyPath"] = signingKeyPath,
|
||||
["Authority:Signing:KeySource"] = "file",
|
||||
["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Signing.Enabled = true;
|
||||
options.Signing.ActiveKeyId = signingKeyId;
|
||||
options.Signing.KeyPath = signingKeyPath;
|
||||
options.Signing.KeySource = "file";
|
||||
options.Signing.Algorithm = SignatureAlgorithms.Es256;
|
||||
options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true;
|
||||
options.VulnerabilityExplorer.Attachments.Enabled = true;
|
||||
});
|
||||
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly List<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyList<AuthEventRecord> Events => events;
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Authority.Vulnerability.Attachments;
|
||||
using StellaOps.Authority.Vulnerability.Workflow;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Vulnerability;
|
||||
|
||||
public sealed class VulnWorkflowTokenEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string SigningActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ACTIVEKEYID";
|
||||
private const string SigningKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYPATH";
|
||||
private const string SigningKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYSOURCE";
|
||||
private const string SigningAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ALGORITHM";
|
||||
|
||||
public VulnWorkflowTokenEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign", "comment" },
|
||||
context = new Dictionary<string, string> { ["finding_id"] = "F-123" },
|
||||
nonce = "workflow-nonce-123456",
|
||||
expiresInSeconds = 600
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
var issueBody = await issueResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}");
|
||||
|
||||
var issued = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryIssueResponse>(
|
||||
issueBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("workflow-nonce-123456", issued!.Nonce);
|
||||
Assert.Contains("assign", issued.Actions);
|
||||
Assert.Contains("comment", issued.Actions);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
RequiredAction = "assign",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-123456"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
var verifyBody = await verifyResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}");
|
||||
|
||||
var verified = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryVerifyResponse>(
|
||||
verifyBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("tenant-default", verified!.Tenant);
|
||||
Assert.Equal("workflow-nonce-123456", verified.Nonce);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.workflow.actor");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = Array.Empty<string>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var error = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_request", error!["error"]);
|
||||
Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:20:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign" },
|
||||
nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnWorkflowAntiForgeryIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
RequiredAction = "close",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123",
|
||||
FindingId = "find-456",
|
||||
ContentHash = "sha256:abc123",
|
||||
ContentType = "application/pdf",
|
||||
Metadata = new Dictionary<string, string?> { ["origin"] = "vuln-workflow" }
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("attach-123", issued!.AttachmentId);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
|
||||
var verified = await verifyResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenVerifyResponse>();
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("ledger-hash-001", verified!.LedgerEventHash);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-999",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateSignedAuthorityApp(
|
||||
RecordingAuthEventSink sink,
|
||||
FakeTimeProvider timeProvider,
|
||||
string signingKeyId,
|
||||
string signingKeyPath)
|
||||
{
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = signingKeyId,
|
||||
["Authority:Signing:KeyPath"] = signingKeyPath,
|
||||
["Authority:Signing:KeySource"] = "file",
|
||||
["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Signing.Enabled = true;
|
||||
options.Signing.ActiveKeyId = signingKeyId;
|
||||
options.Signing.KeyPath = signingKeyPath;
|
||||
options.Signing.KeySource = "file";
|
||||
options.Signing.Algorithm = SignatureAlgorithms.Es256;
|
||||
options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true;
|
||||
options.VulnerabilityExplorer.Attachments.Enabled = true;
|
||||
});
|
||||
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly List<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyList<AuthEventRecord> Events => events;
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Airgap;
|
||||
|
||||
|
||||
@@ -1,237 +1,237 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Audit;
|
||||
|
||||
internal sealed class AuthorityAuditSink : IAuthEventSink
|
||||
{
|
||||
private static readonly StringComparer OrdinalComparer = StringComparer.Ordinal;
|
||||
|
||||
private readonly IAuthorityLoginAttemptStore loginAttemptStore;
|
||||
private readonly ILogger<AuthorityAuditSink> logger;
|
||||
|
||||
public AuthorityAuditSink(
|
||||
IAuthorityLoginAttemptStore loginAttemptStore,
|
||||
ILogger<AuthorityAuditSink> logger)
|
||||
{
|
||||
this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var logState = BuildLogScope(record);
|
||||
using (logger.BeginScope(logState))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Authority audit event {EventType} emitted with outcome {Outcome}.",
|
||||
record.EventType,
|
||||
NormalizeOutcome(record.Outcome));
|
||||
}
|
||||
|
||||
var document = MapToDocument(record);
|
||||
await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AuthorityLoginAttemptDocument MapToDocument(AuthEventRecord record)
|
||||
{
|
||||
var document = new AuthorityLoginAttemptDocument
|
||||
{
|
||||
EventType = record.EventType,
|
||||
Outcome = NormalizeOutcome(record.Outcome),
|
||||
CorrelationId = Normalize(record.CorrelationId),
|
||||
SubjectId = record.Subject?.SubjectId.Value,
|
||||
Username = record.Subject?.Username.Value,
|
||||
ClientId = record.Client?.ClientId.Value,
|
||||
Plugin = record.Client?.Provider.Value,
|
||||
Successful = record.Outcome == AuthEventOutcome.Success,
|
||||
Reason = Normalize(record.Reason),
|
||||
RemoteAddress = record.Network?.RemoteAddress.Value ?? record.Network?.ForwardedFor.Value,
|
||||
OccurredAt = record.OccurredAt
|
||||
};
|
||||
|
||||
if (record.Tenant.HasValue)
|
||||
{
|
||||
document.Tenant = record.Tenant.Value;
|
||||
}
|
||||
|
||||
if (record.Scopes is { Count: > 0 })
|
||||
{
|
||||
document.Scopes = record.Scopes
|
||||
.Where(static scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(static scope => scope.Trim())
|
||||
.Where(static scope => scope.Length > 0)
|
||||
.Distinct(OrdinalComparer)
|
||||
.OrderBy(static scope => scope, OrdinalComparer)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var properties = new List<AuthorityLoginAttemptPropertyDocument>();
|
||||
|
||||
if (record.Subject is { } subject)
|
||||
{
|
||||
AddProperty(properties, "subject.display_name", subject.DisplayName);
|
||||
AddProperty(properties, "subject.realm", subject.Realm);
|
||||
|
||||
if (subject.Attributes is { Count: > 0 })
|
||||
{
|
||||
foreach (var attribute in subject.Attributes)
|
||||
{
|
||||
AddProperty(properties, $"subject.attr.{attribute.Name}", attribute.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.Client is { } client)
|
||||
{
|
||||
AddProperty(properties, "client.name", client.Name);
|
||||
}
|
||||
|
||||
if (record.Network is { } network)
|
||||
{
|
||||
AddProperty(properties, "network.remote", network.RemoteAddress);
|
||||
AddProperty(properties, "network.forwarded_for", network.ForwardedFor);
|
||||
AddProperty(properties, "network.user_agent", network.UserAgent);
|
||||
}
|
||||
|
||||
if (record.Properties is { Count: > 0 })
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
{
|
||||
AddProperty(properties, property.Name, property.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.Count > 0)
|
||||
{
|
||||
document.Properties = properties;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<KeyValuePair<string, object?>> BuildLogScope(AuthEventRecord record)
|
||||
{
|
||||
var entries = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("audit.event_type", record.EventType),
|
||||
new("audit.outcome", NormalizeOutcome(record.Outcome)),
|
||||
new("audit.timestamp", record.OccurredAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
};
|
||||
|
||||
AddValue(entries, "audit.correlation_id", Normalize(record.CorrelationId));
|
||||
AddValue(entries, "audit.reason", Normalize(record.Reason));
|
||||
|
||||
if (record.Subject is { } subject)
|
||||
{
|
||||
AddClassified(entries, "audit.subject.id", subject.SubjectId);
|
||||
AddClassified(entries, "audit.subject.username", subject.Username);
|
||||
AddClassified(entries, "audit.subject.display_name", subject.DisplayName);
|
||||
AddClassified(entries, "audit.subject.realm", subject.Realm);
|
||||
}
|
||||
|
||||
if (record.Client is { } client)
|
||||
{
|
||||
AddClassified(entries, "audit.client.id", client.ClientId);
|
||||
AddClassified(entries, "audit.client.name", client.Name);
|
||||
AddClassified(entries, "audit.client.provider", client.Provider);
|
||||
}
|
||||
|
||||
AddClassified(entries, "audit.tenant", record.Tenant);
|
||||
|
||||
if (record.Network is { } network)
|
||||
{
|
||||
AddClassified(entries, "audit.network.remote", network.RemoteAddress);
|
||||
AddClassified(entries, "audit.network.forwarded_for", network.ForwardedFor);
|
||||
AddClassified(entries, "audit.network.user_agent", network.UserAgent);
|
||||
}
|
||||
|
||||
if (record.Scopes is { Count: > 0 })
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, object?>(
|
||||
"audit.scopes",
|
||||
record.Scopes.Where(static scope => !string.IsNullOrWhiteSpace(scope)).ToArray()));
|
||||
}
|
||||
|
||||
if (record.Properties is { Count: > 0 })
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
{
|
||||
AddClassified(entries, $"audit.property.{property.Name}", property.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddProperty(ICollection<AuthorityLoginAttemptPropertyDocument> properties, string name, ClassifiedString value)
|
||||
{
|
||||
if (!value.HasValue || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
properties.Add(new AuthorityLoginAttemptPropertyDocument
|
||||
{
|
||||
Name = name,
|
||||
Value = value.Value,
|
||||
Classification = NormalizeClassification(value.Classification)
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddValue(ICollection<KeyValuePair<string, object?>> entries, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entries.Add(new KeyValuePair<string, object?>(key, value));
|
||||
}
|
||||
|
||||
private static void AddClassified(ICollection<KeyValuePair<string, object?>> entries, string key, ClassifiedString value)
|
||||
{
|
||||
if (!value.HasValue || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entries.Add(new KeyValuePair<string, object?>(key, new
|
||||
{
|
||||
value.Value,
|
||||
classification = NormalizeClassification(value.Classification)
|
||||
}));
|
||||
}
|
||||
|
||||
private static string NormalizeOutcome(AuthEventOutcome outcome)
|
||||
=> outcome switch
|
||||
{
|
||||
AuthEventOutcome.Success => "success",
|
||||
AuthEventOutcome.Failure => "failure",
|
||||
AuthEventOutcome.LockedOut => "locked_out",
|
||||
AuthEventOutcome.RateLimited => "rate_limited",
|
||||
AuthEventOutcome.Error => "error",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string NormalizeClassification(AuthEventDataClassification classification)
|
||||
=> classification switch
|
||||
{
|
||||
AuthEventDataClassification.Personal => "personal",
|
||||
AuthEventDataClassification.Sensitive => "sensitive",
|
||||
_ => "none"
|
||||
};
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Audit;
|
||||
|
||||
internal sealed class AuthorityAuditSink : IAuthEventSink
|
||||
{
|
||||
private static readonly StringComparer OrdinalComparer = StringComparer.Ordinal;
|
||||
|
||||
private readonly IAuthorityLoginAttemptStore loginAttemptStore;
|
||||
private readonly ILogger<AuthorityAuditSink> logger;
|
||||
|
||||
public AuthorityAuditSink(
|
||||
IAuthorityLoginAttemptStore loginAttemptStore,
|
||||
ILogger<AuthorityAuditSink> logger)
|
||||
{
|
||||
this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var logState = BuildLogScope(record);
|
||||
using (logger.BeginScope(logState))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Authority audit event {EventType} emitted with outcome {Outcome}.",
|
||||
record.EventType,
|
||||
NormalizeOutcome(record.Outcome));
|
||||
}
|
||||
|
||||
var document = MapToDocument(record);
|
||||
await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AuthorityLoginAttemptDocument MapToDocument(AuthEventRecord record)
|
||||
{
|
||||
var document = new AuthorityLoginAttemptDocument
|
||||
{
|
||||
EventType = record.EventType,
|
||||
Outcome = NormalizeOutcome(record.Outcome),
|
||||
CorrelationId = Normalize(record.CorrelationId),
|
||||
SubjectId = record.Subject?.SubjectId.Value,
|
||||
Username = record.Subject?.Username.Value,
|
||||
ClientId = record.Client?.ClientId.Value,
|
||||
Plugin = record.Client?.Provider.Value,
|
||||
Successful = record.Outcome == AuthEventOutcome.Success,
|
||||
Reason = Normalize(record.Reason),
|
||||
RemoteAddress = record.Network?.RemoteAddress.Value ?? record.Network?.ForwardedFor.Value,
|
||||
OccurredAt = record.OccurredAt
|
||||
};
|
||||
|
||||
if (record.Tenant.HasValue)
|
||||
{
|
||||
document.Tenant = record.Tenant.Value;
|
||||
}
|
||||
|
||||
if (record.Scopes is { Count: > 0 })
|
||||
{
|
||||
document.Scopes = record.Scopes
|
||||
.Where(static scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(static scope => scope.Trim())
|
||||
.Where(static scope => scope.Length > 0)
|
||||
.Distinct(OrdinalComparer)
|
||||
.OrderBy(static scope => scope, OrdinalComparer)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var properties = new List<AuthorityLoginAttemptPropertyDocument>();
|
||||
|
||||
if (record.Subject is { } subject)
|
||||
{
|
||||
AddProperty(properties, "subject.display_name", subject.DisplayName);
|
||||
AddProperty(properties, "subject.realm", subject.Realm);
|
||||
|
||||
if (subject.Attributes is { Count: > 0 })
|
||||
{
|
||||
foreach (var attribute in subject.Attributes)
|
||||
{
|
||||
AddProperty(properties, $"subject.attr.{attribute.Name}", attribute.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.Client is { } client)
|
||||
{
|
||||
AddProperty(properties, "client.name", client.Name);
|
||||
}
|
||||
|
||||
if (record.Network is { } network)
|
||||
{
|
||||
AddProperty(properties, "network.remote", network.RemoteAddress);
|
||||
AddProperty(properties, "network.forwarded_for", network.ForwardedFor);
|
||||
AddProperty(properties, "network.user_agent", network.UserAgent);
|
||||
}
|
||||
|
||||
if (record.Properties is { Count: > 0 })
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
{
|
||||
AddProperty(properties, property.Name, property.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.Count > 0)
|
||||
{
|
||||
document.Properties = properties;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<KeyValuePair<string, object?>> BuildLogScope(AuthEventRecord record)
|
||||
{
|
||||
var entries = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("audit.event_type", record.EventType),
|
||||
new("audit.outcome", NormalizeOutcome(record.Outcome)),
|
||||
new("audit.timestamp", record.OccurredAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
};
|
||||
|
||||
AddValue(entries, "audit.correlation_id", Normalize(record.CorrelationId));
|
||||
AddValue(entries, "audit.reason", Normalize(record.Reason));
|
||||
|
||||
if (record.Subject is { } subject)
|
||||
{
|
||||
AddClassified(entries, "audit.subject.id", subject.SubjectId);
|
||||
AddClassified(entries, "audit.subject.username", subject.Username);
|
||||
AddClassified(entries, "audit.subject.display_name", subject.DisplayName);
|
||||
AddClassified(entries, "audit.subject.realm", subject.Realm);
|
||||
}
|
||||
|
||||
if (record.Client is { } client)
|
||||
{
|
||||
AddClassified(entries, "audit.client.id", client.ClientId);
|
||||
AddClassified(entries, "audit.client.name", client.Name);
|
||||
AddClassified(entries, "audit.client.provider", client.Provider);
|
||||
}
|
||||
|
||||
AddClassified(entries, "audit.tenant", record.Tenant);
|
||||
|
||||
if (record.Network is { } network)
|
||||
{
|
||||
AddClassified(entries, "audit.network.remote", network.RemoteAddress);
|
||||
AddClassified(entries, "audit.network.forwarded_for", network.ForwardedFor);
|
||||
AddClassified(entries, "audit.network.user_agent", network.UserAgent);
|
||||
}
|
||||
|
||||
if (record.Scopes is { Count: > 0 })
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, object?>(
|
||||
"audit.scopes",
|
||||
record.Scopes.Where(static scope => !string.IsNullOrWhiteSpace(scope)).ToArray()));
|
||||
}
|
||||
|
||||
if (record.Properties is { Count: > 0 })
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
{
|
||||
AddClassified(entries, $"audit.property.{property.Name}", property.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddProperty(ICollection<AuthorityLoginAttemptPropertyDocument> properties, string name, ClassifiedString value)
|
||||
{
|
||||
if (!value.HasValue || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
properties.Add(new AuthorityLoginAttemptPropertyDocument
|
||||
{
|
||||
Name = name,
|
||||
Value = value.Value,
|
||||
Classification = NormalizeClassification(value.Classification)
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddValue(ICollection<KeyValuePair<string, object?>> entries, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entries.Add(new KeyValuePair<string, object?>(key, value));
|
||||
}
|
||||
|
||||
private static void AddClassified(ICollection<KeyValuePair<string, object?>> entries, string key, ClassifiedString value)
|
||||
{
|
||||
if (!value.HasValue || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entries.Add(new KeyValuePair<string, object?>(key, new
|
||||
{
|
||||
value.Value,
|
||||
classification = NormalizeClassification(value.Classification)
|
||||
}));
|
||||
}
|
||||
|
||||
private static string NormalizeOutcome(AuthEventOutcome outcome)
|
||||
=> outcome switch
|
||||
{
|
||||
AuthEventOutcome.Success => "success",
|
||||
AuthEventOutcome.Failure => "failure",
|
||||
AuthEventOutcome.LockedOut => "locked_out",
|
||||
AuthEventOutcome.RateLimited => "rate_limited",
|
||||
AuthEventOutcome.Error => "error",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string NormalizeClassification(AuthEventDataClassification classification)
|
||||
=> classification switch
|
||||
{
|
||||
AuthEventDataClassification.Personal => "personal",
|
||||
AuthEventDataClassification.Sensitive => "sensitive",
|
||||
_ => "none"
|
||||
};
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal static class AuthorityHttpHeaders
|
||||
{
|
||||
public const string Tenant = "X-StellaOps-Tenant";
|
||||
public const string Project = "X-StellaOps-Project";
|
||||
}
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal static class AuthorityHttpHeaders
|
||||
{
|
||||
public const string Tenant = "X-StellaOps-Tenant";
|
||||
public const string Project = "X-StellaOps-Project";
|
||||
}
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProviderRegistry
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
private readonly IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProviderRegistry
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
private readonly IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> bootstrapProviders;
|
||||
|
||||
public AuthorityIdentityProviderRegistry(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AuthorityIdentityProviderRegistry> logger)
|
||||
{
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var providerInstances = scope.ServiceProvider.GetServices<IIdentityProviderPlugin>();
|
||||
|
||||
var orderedProviders = providerInstances?
|
||||
.Where(static p => p is not null)
|
||||
.OrderBy(static p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList() ?? new List<IIdentityProviderPlugin>();
|
||||
|
||||
var uniqueProviders = new List<AuthorityIdentityProviderMetadata>(orderedProviders.Count);
|
||||
var password = new List<AuthorityIdentityProviderMetadata>();
|
||||
var mfa = new List<AuthorityIdentityProviderMetadata>();
|
||||
|
||||
public AuthorityIdentityProviderRegistry(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AuthorityIdentityProviderRegistry> logger)
|
||||
{
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var providerInstances = scope.ServiceProvider.GetServices<IIdentityProviderPlugin>();
|
||||
|
||||
var orderedProviders = providerInstances?
|
||||
.Where(static p => p is not null)
|
||||
.OrderBy(static p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList() ?? new List<IIdentityProviderPlugin>();
|
||||
|
||||
var uniqueProviders = new List<AuthorityIdentityProviderMetadata>(orderedProviders.Count);
|
||||
var password = new List<AuthorityIdentityProviderMetadata>();
|
||||
var mfa = new List<AuthorityIdentityProviderMetadata>();
|
||||
var clientProvisioning = new List<AuthorityIdentityProviderMetadata>();
|
||||
var bootstrap = new List<AuthorityIdentityProviderMetadata>();
|
||||
|
||||
var dictionary = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in orderedProviders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provider.Name))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Identity provider plugin of type '{PluginType}' was registered with an empty name and will be ignored.",
|
||||
provider.Type);
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = new AuthorityIdentityProviderMetadata(provider.Name, provider.Type, provider.Capabilities);
|
||||
|
||||
if (!dictionary.TryAdd(provider.Name, metadata))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Duplicate identity provider name '{PluginName}' detected; ignoring additional registration for type '{PluginType}'.",
|
||||
provider.Name,
|
||||
provider.Type);
|
||||
continue;
|
||||
}
|
||||
|
||||
uniqueProviders.Add(metadata);
|
||||
|
||||
if (metadata.Capabilities.SupportsPassword)
|
||||
{
|
||||
password.Add(metadata);
|
||||
}
|
||||
|
||||
if (metadata.Capabilities.SupportsMfa)
|
||||
{
|
||||
mfa.Add(metadata);
|
||||
}
|
||||
|
||||
|
||||
var dictionary = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in orderedProviders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provider.Name))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Identity provider plugin of type '{PluginType}' was registered with an empty name and will be ignored.",
|
||||
provider.Type);
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = new AuthorityIdentityProviderMetadata(provider.Name, provider.Type, provider.Capabilities);
|
||||
|
||||
if (!dictionary.TryAdd(provider.Name, metadata))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Duplicate identity provider name '{PluginName}' detected; ignoring additional registration for type '{PluginType}'.",
|
||||
provider.Name,
|
||||
provider.Type);
|
||||
continue;
|
||||
}
|
||||
|
||||
uniqueProviders.Add(metadata);
|
||||
|
||||
if (metadata.Capabilities.SupportsPassword)
|
||||
{
|
||||
password.Add(metadata);
|
||||
}
|
||||
|
||||
if (metadata.Capabilities.SupportsMfa)
|
||||
{
|
||||
mfa.Add(metadata);
|
||||
}
|
||||
|
||||
if (metadata.Capabilities.SupportsClientProvisioning)
|
||||
{
|
||||
clientProvisioning.Add(metadata);
|
||||
@@ -85,7 +85,7 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
|
||||
bootstrap.Add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
providersByName = dictionary;
|
||||
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(uniqueProviders);
|
||||
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(password);
|
||||
@@ -98,60 +98,60 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
|
||||
SupportsMfa: mfaProviders.Count > 0,
|
||||
SupportsClientProvisioning: clientProvisioningProviders.Count > 0,
|
||||
SupportsBootstrap: bootstrapProviders.Count > 0);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders => passwordProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
|
||||
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders => passwordProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders => bootstrapProviders;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
|
||||
|
||||
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
metadata = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return providersByName.TryGetValue(name, out metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!providersByName.TryGetValue(name, out var metadata))
|
||||
{
|
||||
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var scope = serviceProvider.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
var provider = scope.ServiceProvider
|
||||
.GetServices<IIdentityProviderPlugin>()
|
||||
.FirstOrDefault(p => string.Equals(p.Name, metadata.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Identity provider plugin '{metadata.Name}' could not be resolved.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new AuthorityIdentityProviderHandle(scope, metadata, provider);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
|
||||
|
||||
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
metadata = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return providersByName.TryGetValue(name, out metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!providersByName.TryGetValue(name, out var metadata))
|
||||
{
|
||||
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var scope = serviceProvider.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
var provider = scope.ServiceProvider
|
||||
.GetServices<IIdentityProviderPlugin>()
|
||||
.FirstOrDefault(p => string.Equals(p.Name, metadata.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Identity provider plugin '{metadata.Name}' could not be resolved.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new AuthorityIdentityProviderHandle(scope, metadata, provider);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Bootstrap;
|
||||
|
||||
@@ -1,96 +1,96 @@
|
||||
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; }
|
||||
}
|
||||
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; }
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +1,75 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Console;
|
||||
|
||||
internal sealed class TenantHeaderFilter : IEndpointFilter
|
||||
{
|
||||
private const string TenantItemKey = "__authority-console-tenant";
|
||||
|
||||
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
|
||||
var httpContext = context.HttpContext;
|
||||
var principal = httpContext.User;
|
||||
|
||||
if (principal?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Unauthorized());
|
||||
}
|
||||
|
||||
var tenantHeader = httpContext.Request.Headers[AuthorityHttpHeaders.Tenant];
|
||||
if (IsMissing(tenantHeader))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.BadRequest(new
|
||||
{
|
||||
error = "tenant_header_missing",
|
||||
message = $"Header '{AuthorityHttpHeaders.Tenant}' is required."
|
||||
}));
|
||||
}
|
||||
|
||||
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
|
||||
var claimTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
httpContext.Items[TenantItemKey] = normalizedHeader;
|
||||
return next(context);
|
||||
}
|
||||
|
||||
internal static string? GetTenant(HttpContext httpContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
if (httpContext.Items.TryGetValue(TenantItemKey, out var value) && value is string tenant && !string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsMissing(StringValues values)
|
||||
{
|
||||
if (StringValues.IsNullOrEmpty(values))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var value = values.ToString();
|
||||
return string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
}
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Console;
|
||||
|
||||
internal sealed class TenantHeaderFilter : IEndpointFilter
|
||||
{
|
||||
private const string TenantItemKey = "__authority-console-tenant";
|
||||
|
||||
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
|
||||
var httpContext = context.HttpContext;
|
||||
var principal = httpContext.User;
|
||||
|
||||
if (principal?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Unauthorized());
|
||||
}
|
||||
|
||||
var tenantHeader = httpContext.Request.Headers[AuthorityHttpHeaders.Tenant];
|
||||
if (IsMissing(tenantHeader))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.BadRequest(new
|
||||
{
|
||||
error = "tenant_header_missing",
|
||||
message = $"Header '{AuthorityHttpHeaders.Tenant}' is required."
|
||||
}));
|
||||
}
|
||||
|
||||
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
|
||||
var claimTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
httpContext.Items[TenantItemKey] = normalizedHeader;
|
||||
return next(context);
|
||||
}
|
||||
|
||||
internal static string? GetTenant(HttpContext httpContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
if (httpContext.Items.TryGetValue(TenantItemKey, out var value) && value is string tenant && !string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsMissing(StringValues values)
|
||||
{
|
||||
if (StringValues.IsNullOrEmpty(values))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var value = values.ToString();
|
||||
return string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,254 +1,254 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal sealed class LegacyAuthDeprecationMiddleware
|
||||
{
|
||||
private const string LegacyEventType = "authority.api.legacy_endpoint";
|
||||
private const string SunsetHeaderName = "Sunset";
|
||||
|
||||
private static readonly IReadOnlyDictionary<PathString, PathString> LegacyEndpointMap =
|
||||
new Dictionary<PathString, PathString>(PathStringComparer.Instance)
|
||||
{
|
||||
[new PathString("/oauth/token")] = new PathString("/token"),
|
||||
[new PathString("/oauth/introspect")] = new PathString("/introspect"),
|
||||
[new PathString("/oauth/revoke")] = new PathString("/revoke")
|
||||
};
|
||||
|
||||
private readonly RequestDelegate next;
|
||||
private readonly AuthorityLegacyAuthEndpointOptions options;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<LegacyAuthDeprecationMiddleware> logger;
|
||||
|
||||
public LegacyAuthDeprecationMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider clock,
|
||||
ILogger<LegacyAuthDeprecationMiddleware> logger)
|
||||
{
|
||||
this.next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
options = authorityOptions.Value.ApiLifecycle.LegacyAuth ??
|
||||
throw new InvalidOperationException("Authority legacy auth endpoint options are not configured.");
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveLegacyPath(context.Request.Path, out var canonicalPath))
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalPath = context.Request.Path;
|
||||
context.Request.Path = canonicalPath;
|
||||
|
||||
logger.LogInformation(
|
||||
"Legacy Authority endpoint {OriginalPath} invoked; routing to {CanonicalPath} and emitting deprecation headers.",
|
||||
originalPath,
|
||||
canonicalPath);
|
||||
|
||||
AppendDeprecationHeaders(context.Response);
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
|
||||
await EmitAuditAsync(context, originalPath, canonicalPath).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool TryResolveLegacyPath(PathString path, out PathString canonicalPath)
|
||||
{
|
||||
if (LegacyEndpointMap.TryGetValue(Normalize(path), out canonicalPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
canonicalPath = PathString.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PathString Normalize(PathString value)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return PathString.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Value!.TrimEnd('/');
|
||||
return new PathString(trimmed.Length == 0 ? "/" : trimmed.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private void AppendDeprecationHeaders(HttpResponse response)
|
||||
{
|
||||
if (response.HasStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deprecation = FormatHttpDate(options.DeprecationDate);
|
||||
response.Headers["Deprecation"] = deprecation;
|
||||
|
||||
var sunset = FormatHttpDate(options.SunsetDate);
|
||||
response.Headers[SunsetHeaderName] = sunset;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.DocumentationUrl))
|
||||
{
|
||||
var linkValue = $"<{options.DocumentationUrl}>; rel=\"sunset\"";
|
||||
response.Headers.Append(HeaderNames.Link, linkValue);
|
||||
}
|
||||
|
||||
var warning = $"299 - \"Legacy Authority endpoint will be removed after {sunset}. Migrate to canonical endpoints before the sunset date.\"";
|
||||
response.Headers[HeaderNames.Warning] = warning;
|
||||
}
|
||||
|
||||
private async Task EmitAuditAsync(HttpContext context, PathString originalPath, PathString canonicalPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var correlation = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
|
||||
|
||||
var network = BuildNetwork(context);
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = LegacyEventType,
|
||||
OccurredAt = clock.GetUtcNow(),
|
||||
CorrelationId = correlation,
|
||||
Outcome = AuthEventOutcome.Success,
|
||||
Reason = null,
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Tenant = ClassifiedString.Empty,
|
||||
Project = ClassifiedString.Empty,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = network,
|
||||
Properties = BuildProperties(
|
||||
("legacy.endpoint.original", originalPath.Value),
|
||||
("legacy.endpoint.canonical", canonicalPath.Value),
|
||||
("legacy.deprecation_at", options.DeprecationDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("legacy.sunset_at", options.SunsetDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("http.status_code", context.Response.StatusCode.ToString(CultureInfo.InvariantCulture)))
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to emit legacy auth endpoint audit event.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(HttpContext context)
|
||||
{
|
||||
var remote = context.Connection.RemoteIpAddress?.ToString();
|
||||
var forwarded = context.Request.Headers["X-Forwarded-For"].ToString();
|
||||
var userAgent = context.Request.Headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) &&
|
||||
string.IsNullOrWhiteSpace(forwarded) &&
|
||||
string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(Normalize(remote)),
|
||||
ForwardedFor = ClassifiedString.Personal(Normalize(forwarded)),
|
||||
UserAgent = ClassifiedString.Personal(Normalize(userAgent))
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(params (string Name, string? Value)[] entries)
|
||||
{
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
var list = new List<AuthEventProperty>(entries.Length);
|
||||
foreach (var (name, value) in entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new AuthEventProperty
|
||||
{
|
||||
Name = name,
|
||||
Value = string.IsNullOrWhiteSpace(value)
|
||||
? ClassifiedString.Empty
|
||||
: ClassifiedString.Public(value)
|
||||
});
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
|
||||
}
|
||||
|
||||
private static string FormatHttpDate(DateTimeOffset value)
|
||||
{
|
||||
return value.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class PathStringComparer : IEqualityComparer<PathString>
|
||||
{
|
||||
public static readonly PathStringComparer Instance = new();
|
||||
|
||||
public bool Equals(PathString x, PathString y)
|
||||
{
|
||||
return string.Equals(Normalize(x).Value, Normalize(y).Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(PathString obj)
|
||||
{
|
||||
return Normalize(obj).Value?.GetHashCode(StringComparison.Ordinal) ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class LegacyAuthDeprecationExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseLegacyAuthDeprecation(this IApplicationBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
return app.UseMiddleware<LegacyAuthDeprecationMiddleware>();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal sealed class LegacyAuthDeprecationMiddleware
|
||||
{
|
||||
private const string LegacyEventType = "authority.api.legacy_endpoint";
|
||||
private const string SunsetHeaderName = "Sunset";
|
||||
|
||||
private static readonly IReadOnlyDictionary<PathString, PathString> LegacyEndpointMap =
|
||||
new Dictionary<PathString, PathString>(PathStringComparer.Instance)
|
||||
{
|
||||
[new PathString("/oauth/token")] = new PathString("/token"),
|
||||
[new PathString("/oauth/introspect")] = new PathString("/introspect"),
|
||||
[new PathString("/oauth/revoke")] = new PathString("/revoke")
|
||||
};
|
||||
|
||||
private readonly RequestDelegate next;
|
||||
private readonly AuthorityLegacyAuthEndpointOptions options;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<LegacyAuthDeprecationMiddleware> logger;
|
||||
|
||||
public LegacyAuthDeprecationMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider clock,
|
||||
ILogger<LegacyAuthDeprecationMiddleware> logger)
|
||||
{
|
||||
this.next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
options = authorityOptions.Value.ApiLifecycle.LegacyAuth ??
|
||||
throw new InvalidOperationException("Authority legacy auth endpoint options are not configured.");
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveLegacyPath(context.Request.Path, out var canonicalPath))
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalPath = context.Request.Path;
|
||||
context.Request.Path = canonicalPath;
|
||||
|
||||
logger.LogInformation(
|
||||
"Legacy Authority endpoint {OriginalPath} invoked; routing to {CanonicalPath} and emitting deprecation headers.",
|
||||
originalPath,
|
||||
canonicalPath);
|
||||
|
||||
AppendDeprecationHeaders(context.Response);
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
|
||||
await EmitAuditAsync(context, originalPath, canonicalPath).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool TryResolveLegacyPath(PathString path, out PathString canonicalPath)
|
||||
{
|
||||
if (LegacyEndpointMap.TryGetValue(Normalize(path), out canonicalPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
canonicalPath = PathString.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PathString Normalize(PathString value)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return PathString.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Value!.TrimEnd('/');
|
||||
return new PathString(trimmed.Length == 0 ? "/" : trimmed.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private void AppendDeprecationHeaders(HttpResponse response)
|
||||
{
|
||||
if (response.HasStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deprecation = FormatHttpDate(options.DeprecationDate);
|
||||
response.Headers["Deprecation"] = deprecation;
|
||||
|
||||
var sunset = FormatHttpDate(options.SunsetDate);
|
||||
response.Headers[SunsetHeaderName] = sunset;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.DocumentationUrl))
|
||||
{
|
||||
var linkValue = $"<{options.DocumentationUrl}>; rel=\"sunset\"";
|
||||
response.Headers.Append(HeaderNames.Link, linkValue);
|
||||
}
|
||||
|
||||
var warning = $"299 - \"Legacy Authority endpoint will be removed after {sunset}. Migrate to canonical endpoints before the sunset date.\"";
|
||||
response.Headers[HeaderNames.Warning] = warning;
|
||||
}
|
||||
|
||||
private async Task EmitAuditAsync(HttpContext context, PathString originalPath, PathString canonicalPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var correlation = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
|
||||
|
||||
var network = BuildNetwork(context);
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = LegacyEventType,
|
||||
OccurredAt = clock.GetUtcNow(),
|
||||
CorrelationId = correlation,
|
||||
Outcome = AuthEventOutcome.Success,
|
||||
Reason = null,
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Tenant = ClassifiedString.Empty,
|
||||
Project = ClassifiedString.Empty,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = network,
|
||||
Properties = BuildProperties(
|
||||
("legacy.endpoint.original", originalPath.Value),
|
||||
("legacy.endpoint.canonical", canonicalPath.Value),
|
||||
("legacy.deprecation_at", options.DeprecationDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("legacy.sunset_at", options.SunsetDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("http.status_code", context.Response.StatusCode.ToString(CultureInfo.InvariantCulture)))
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to emit legacy auth endpoint audit event.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(HttpContext context)
|
||||
{
|
||||
var remote = context.Connection.RemoteIpAddress?.ToString();
|
||||
var forwarded = context.Request.Headers["X-Forwarded-For"].ToString();
|
||||
var userAgent = context.Request.Headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) &&
|
||||
string.IsNullOrWhiteSpace(forwarded) &&
|
||||
string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(Normalize(remote)),
|
||||
ForwardedFor = ClassifiedString.Personal(Normalize(forwarded)),
|
||||
UserAgent = ClassifiedString.Personal(Normalize(userAgent))
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(params (string Name, string? Value)[] entries)
|
||||
{
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
var list = new List<AuthEventProperty>(entries.Length);
|
||||
foreach (var (name, value) in entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new AuthEventProperty
|
||||
{
|
||||
Name = name,
|
||||
Value = string.IsNullOrWhiteSpace(value)
|
||||
? ClassifiedString.Empty
|
||||
: ClassifiedString.Public(value)
|
||||
});
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
|
||||
}
|
||||
|
||||
private static string FormatHttpDate(DateTimeOffset value)
|
||||
{
|
||||
return value.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class PathStringComparer : IEqualityComparer<PathString>
|
||||
{
|
||||
public static readonly PathStringComparer Instance = new();
|
||||
|
||||
public bool Equals(PathString x, PathString y)
|
||||
{
|
||||
return string.Equals(Normalize(x).Value, Normalize(y).Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(PathString obj)
|
||||
{
|
||||
return Normalize(obj).Value?.GetHashCode(StringComparison.Ordinal) ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class LegacyAuthDeprecationExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseLegacyAuthDeprecation(this IApplicationBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
return app.UseMiddleware<LegacyAuthDeprecationMiddleware>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Authority.Console;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Observability;
|
||||
|
||||
|
||||
@@ -1,319 +1,319 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.IO;
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
using System.Linq;
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
using System.Text;
|
||||
|
||||
using System.Threading;
|
||||
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using YamlDotNet.Core;
|
||||
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
|
||||
|
||||
internal sealed class AuthorityOpenApiDocumentProvider
|
||||
|
||||
{
|
||||
|
||||
private readonly string specificationPath;
|
||||
|
||||
private readonly ILogger<AuthorityOpenApiDocumentProvider> logger;
|
||||
|
||||
private readonly ICryptoHash hash;
|
||||
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
|
||||
private OpenApiDocumentSnapshot? cached;
|
||||
|
||||
|
||||
|
||||
public AuthorityOpenApiDocumentProvider(
|
||||
|
||||
IWebHostEnvironment environment,
|
||||
|
||||
ILogger<AuthorityOpenApiDocumentProvider> logger,
|
||||
|
||||
ICryptoHash hash)
|
||||
|
||||
{
|
||||
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(hash);
|
||||
|
||||
|
||||
|
||||
specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml");
|
||||
|
||||
this.logger = logger;
|
||||
|
||||
this.hash = hash;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async ValueTask<OpenApiDocumentSnapshot> GetDocumentAsync(CancellationToken cancellationToken)
|
||||
|
||||
{
|
||||
|
||||
var lastWriteUtc = GetLastWriteTimeUtc();
|
||||
|
||||
var current = cached;
|
||||
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
|
||||
{
|
||||
|
||||
return current;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
|
||||
{
|
||||
|
||||
current = cached;
|
||||
|
||||
lastWriteUtc = GetLastWriteTimeUtc();
|
||||
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
|
||||
{
|
||||
|
||||
return current;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var snapshot = LoadSnapshot(lastWriteUtc);
|
||||
|
||||
cached = snapshot;
|
||||
|
||||
return snapshot;
|
||||
|
||||
}
|
||||
|
||||
finally
|
||||
|
||||
{
|
||||
|
||||
refreshLock.Release();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private DateTime GetLastWriteTimeUtc()
|
||||
|
||||
{
|
||||
|
||||
var file = new FileInfo(specificationPath);
|
||||
|
||||
if (!file.Exists)
|
||||
|
||||
{
|
||||
|
||||
throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return file.LastWriteTimeUtc;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc)
|
||||
|
||||
{
|
||||
|
||||
string yamlText;
|
||||
|
||||
try
|
||||
|
||||
{
|
||||
|
||||
yamlText = File.ReadAllText(specificationPath);
|
||||
|
||||
}
|
||||
|
||||
catch (Exception ex)
|
||||
|
||||
{
|
||||
|
||||
logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath);
|
||||
|
||||
throw;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var yamlStream = new YamlStream();
|
||||
|
||||
using (var reader = new StringReader(yamlText))
|
||||
|
||||
{
|
||||
|
||||
yamlStream.Load(reader);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode)
|
||||
|
||||
{
|
||||
|
||||
throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node.");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var (grants, scopes) = CollectGrantsAndScopes(rootNode);
|
||||
|
||||
|
||||
|
||||
if (!TryGetMapping(rootNode, "info", out var infoNode))
|
||||
|
||||
{
|
||||
|
||||
infoNode = new YamlMappingNode();
|
||||
|
||||
rootNode.Children[new YamlScalarNode("info")] = infoNode;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var serviceName = "authority";
|
||||
|
||||
var buildVersion = ResolveBuildVersion();
|
||||
|
||||
ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes);
|
||||
|
||||
|
||||
|
||||
var apiVersion = TryGetScalar(infoNode, "version", out var version)
|
||||
|
||||
? version
|
||||
|
||||
: "0.0.0";
|
||||
|
||||
|
||||
|
||||
var updatedYaml = WriteYaml(yamlStream);
|
||||
|
||||
var json = ConvertYamlToJson(updatedYaml);
|
||||
|
||||
var etag = CreateStrongEtag(json);
|
||||
|
||||
|
||||
|
||||
return new OpenApiDocumentSnapshot(
|
||||
|
||||
serviceName,
|
||||
|
||||
apiVersion,
|
||||
|
||||
buildVersion,
|
||||
|
||||
json,
|
||||
|
||||
updatedYaml,
|
||||
|
||||
etag,
|
||||
|
||||
lastWriteUtc,
|
||||
|
||||
grants,
|
||||
|
||||
scopes);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static (IReadOnlyList<string> Grants, IReadOnlyList<string> Scopes) CollectGrantsAndScopes(YamlMappingNode root)
|
||||
|
||||
{
|
||||
|
||||
if (!TryGetMapping(root, "components", out var components) ||
|
||||
|
||||
!TryGetMapping(components, "securitySchemes", out var securitySchemes))
|
||||
|
||||
{
|
||||
|
||||
return (Array.Empty<string>(), Array.Empty<string>());
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
var grants = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var scopes = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
foreach (var scheme in securitySchemes.Children.Values.OfType<YamlMappingNode>())
|
||||
|
||||
{
|
||||
|
||||
if (!TryGetMapping(scheme, "flows", out var flows))
|
||||
|
||||
{
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
foreach (var flowEntry in flows.Children)
|
||||
|
||||
{
|
||||
|
||||
if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping)
|
||||
|
||||
{
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using StellaOps.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
internal sealed class AuthorityOpenApiDocumentProvider
|
||||
{
|
||||
private readonly string specificationPath;
|
||||
private readonly ILogger<AuthorityOpenApiDocumentProvider> logger;
|
||||
private readonly ICryptoHash hash;
|
||||
private readonly SemaphoreSlim refreshLock = new(1, 1);
|
||||
private OpenApiDocumentSnapshot? cached;
|
||||
|
||||
public AuthorityOpenApiDocumentProvider(
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<AuthorityOpenApiDocumentProvider> logger,
|
||||
ICryptoHash hash)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(hash);
|
||||
|
||||
specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml");
|
||||
this.logger = logger;
|
||||
this.hash = hash;
|
||||
}
|
||||
|
||||
public async ValueTask<OpenApiDocumentSnapshot> GetDocumentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lastWriteUtc = GetLastWriteTimeUtc();
|
||||
var current = cached;
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
current = cached;
|
||||
lastWriteUtc = GetLastWriteTimeUtc();
|
||||
if (current is not null && current.LastWriteUtc == lastWriteUtc)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var snapshot = LoadSnapshot(lastWriteUtc);
|
||||
cached = snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
finally
|
||||
{
|
||||
refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime GetLastWriteTimeUtc()
|
||||
{
|
||||
var file = new FileInfo(specificationPath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath);
|
||||
}
|
||||
|
||||
return file.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc)
|
||||
{
|
||||
string yamlText;
|
||||
try
|
||||
{
|
||||
yamlText = File.ReadAllText(specificationPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
var yamlStream = new YamlStream();
|
||||
using (var reader = new StringReader(yamlText))
|
||||
{
|
||||
yamlStream.Load(reader);
|
||||
}
|
||||
|
||||
if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode)
|
||||
{
|
||||
throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node.");
|
||||
}
|
||||
|
||||
var (grants, scopes) = CollectGrantsAndScopes(rootNode);
|
||||
|
||||
if (!TryGetMapping(rootNode, "info", out var infoNode))
|
||||
{
|
||||
infoNode = new YamlMappingNode();
|
||||
rootNode.Children[new YamlScalarNode("info")] = infoNode;
|
||||
}
|
||||
|
||||
var serviceName = "authority";
|
||||
var buildVersion = ResolveBuildVersion();
|
||||
ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes);
|
||||
|
||||
var apiVersion = TryGetScalar(infoNode, "version", out var version)
|
||||
? version
|
||||
: "0.0.0";
|
||||
|
||||
var updatedYaml = WriteYaml(yamlStream);
|
||||
var json = ConvertYamlToJson(updatedYaml);
|
||||
var etag = CreateStrongEtag(json);
|
||||
|
||||
return new OpenApiDocumentSnapshot(
|
||||
serviceName,
|
||||
apiVersion,
|
||||
buildVersion,
|
||||
json,
|
||||
updatedYaml,
|
||||
etag,
|
||||
lastWriteUtc,
|
||||
grants,
|
||||
scopes);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<string> Grants, IReadOnlyList<string> Scopes) CollectGrantsAndScopes(YamlMappingNode root)
|
||||
{
|
||||
if (!TryGetMapping(root, "components", out var components) ||
|
||||
!TryGetMapping(components, "securitySchemes", out var securitySchemes))
|
||||
{
|
||||
return (Array.Empty<string>(), Array.Empty<string>());
|
||||
}
|
||||
|
||||
var grants = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var scopes = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var scheme in securitySchemes.Children.Values.OfType<YamlMappingNode>())
|
||||
{
|
||||
if (!TryGetMapping(scheme, "flows", out var flows))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var flowEntry in flows.Children)
|
||||
{
|
||||
if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var grant = NormalizeGrantName(flowNameNode.Value);
|
||||
if (grant is not null)
|
||||
{
|
||||
grants.Add(grant);
|
||||
}
|
||||
|
||||
if (TryGetMapping(flowMapping, "scopes", out var scopesMapping))
|
||||
{
|
||||
foreach (var scope in scopesMapping.Children.Keys.OfType<YamlScalarNode>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scope.Value))
|
||||
{
|
||||
scopes.Add(scope.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flowMapping.Children.TryGetValue(new YamlScalarNode("refreshUrl"), out var refreshNode) &&
|
||||
refreshNode is YamlScalarNode refreshScalar && !string.IsNullOrWhiteSpace(refreshScalar.Value))
|
||||
{
|
||||
grants.Add("refresh_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
grants.Count == 0 ? Array.Empty<string>() : grants.ToArray(),
|
||||
scopes.Count == 0 ? Array.Empty<string>() : scopes.ToArray());
|
||||
}
|
||||
|
||||
private static string? NormalizeGrantName(string? flowName)
|
||||
=> flowName switch
|
||||
{
|
||||
null or "" => null,
|
||||
"authorizationCode" => "authorization_code",
|
||||
"clientCredentials" => "client_credentials",
|
||||
"password" => "password",
|
||||
"implicit" => "implicit",
|
||||
"deviceCode" => "device_code",
|
||||
_ => flowName
|
||||
};
|
||||
|
||||
private static void ApplyInfoMetadata(
|
||||
YamlMappingNode infoNode,
|
||||
string serviceName,
|
||||
string buildVersion,
|
||||
IReadOnlyList<string> grants,
|
||||
IReadOnlyList<string> scopes)
|
||||
{
|
||||
infoNode.Children[new YamlScalarNode("x-stella-service")] = new YamlScalarNode(serviceName);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-build-version")] = new YamlScalarNode(buildVersion);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-grant-types")] = CreateSequence(grants);
|
||||
infoNode.Children[new YamlScalarNode("x-stella-scopes")] = CreateSequence(scopes);
|
||||
}
|
||||
|
||||
private static YamlSequenceNode CreateSequence(IEnumerable<string> values)
|
||||
{
|
||||
var sequence = new YamlSequenceNode();
|
||||
foreach (var value in values)
|
||||
{
|
||||
sequence.Add(new YamlScalarNode(value));
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private static bool TryGetMapping(YamlMappingNode node, string key, out YamlMappingNode mapping)
|
||||
{
|
||||
foreach (var entry in node.Children)
|
||||
{
|
||||
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
if (entry.Value is YamlMappingNode mappingNode)
|
||||
{
|
||||
mapping = mappingNode;
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
mapping = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetScalar(YamlMappingNode node, string key, out string value)
|
||||
{
|
||||
foreach (var entry in node.Children)
|
||||
{
|
||||
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
if (entry.Value is YamlScalarNode valueNode)
|
||||
{
|
||||
value = valueNode.Value ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string WriteYaml(YamlStream yamlStream)
|
||||
{
|
||||
using var writer = new StringWriter(CultureInfo.InvariantCulture);
|
||||
yamlStream.Save(writer, assignAnchors: false);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static string ConvertYamlToJson(string yaml)
|
||||
{
|
||||
var deserializer = new DeserializerBuilder().Build();
|
||||
var yamlObject = deserializer.Deserialize(new StringReader(yaml));
|
||||
|
||||
var serializer = new SerializerBuilder()
|
||||
.JsonCompatible()
|
||||
.Build();
|
||||
|
||||
var json = serializer.Serialize(yamlObject);
|
||||
return string.IsNullOrWhiteSpace(json) ? "{}" : json.Trim();
|
||||
}
|
||||
|
||||
private string CreateStrongEtag(string jsonRepresentation)
|
||||
{
|
||||
var digest = hash.ComputeHashHex(Encoding.UTF8.GetBytes(jsonRepresentation), HashAlgorithms.Sha256);
|
||||
return "\"" + digest + "\"";
|
||||
}
|
||||
|
||||
private static string ResolveBuildVersion()
|
||||
{
|
||||
var assembly = typeof(AuthorityOpenApiDocumentProvider).Assembly;
|
||||
var informational = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(informational))
|
||||
{
|
||||
return informational!;
|
||||
}
|
||||
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenApiDocumentSnapshot(
|
||||
string ServiceName,
|
||||
string ApiVersion,
|
||||
string BuildVersion,
|
||||
string Json,
|
||||
string Yaml,
|
||||
string ETag,
|
||||
DateTime LastWriteUtc,
|
||||
IReadOnlyList<string> GrantTypes,
|
||||
IReadOnlyList<string> Scopes);
|
||||
|
||||
@@ -1,143 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
internal static class OpenApiDiscoveryEndpointExtensions
|
||||
{
|
||||
private const string JsonMediaType = "application/openapi+json";
|
||||
private const string YamlMediaType = "application/openapi+yaml";
|
||||
private static readonly string[] AdditionalYamlMediaTypes = { "application/yaml", "text/yaml" };
|
||||
private static readonly string[] AdditionalJsonMediaTypes = { "application/json" };
|
||||
|
||||
public static IEndpointConventionBuilder MapAuthorityOpenApiDiscovery(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var builder = endpoints.MapGet("/.well-known/openapi", async (HttpContext context, [FromServices] AuthorityOpenApiDocumentProvider provider, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var snapshot = await provider.GetDocumentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var preferYaml = ShouldReturnYaml(context.Request.GetTypedHeaders().Accept);
|
||||
var payload = preferYaml ? snapshot.Yaml : snapshot.Json;
|
||||
var mediaType = preferYaml ? YamlMediaType : JsonMediaType;
|
||||
var contentType = string.Create(CultureInfo.InvariantCulture, $"{mediaType}; charset=utf-8");
|
||||
|
||||
ApplyMetadataHeaders(context.Response, snapshot);
|
||||
|
||||
if (MatchesEtag(context.Request.Headers[HeaderNames.IfNoneMatch], snapshot.ETag))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status304NotModified;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = contentType;
|
||||
await context.Response.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return builder.WithName("AuthorityOpenApiDiscovery");
|
||||
}
|
||||
|
||||
private static bool ShouldReturnYaml(IList<MediaTypeHeaderValue>? accept)
|
||||
{
|
||||
if (accept is null || accept.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ordered = accept
|
||||
.OrderByDescending(value => value.Quality ?? 1.0)
|
||||
.ThenByDescending(value => value.MediaType.HasValue && IsYaml(value.MediaType.Value));
|
||||
|
||||
foreach (var value in ordered)
|
||||
{
|
||||
if (!value.MediaType.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mediaType = value.MediaType.Value;
|
||||
if (IsYaml(mediaType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsJson(mediaType) || mediaType.Equals("*/*", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsYaml(string mediaType)
|
||||
=> mediaType.Equals(YamlMediaType, StringComparison.OrdinalIgnoreCase)
|
||||
|| AdditionalYamlMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsJson(string mediaType)
|
||||
=> mediaType.Equals(JsonMediaType, StringComparison.OrdinalIgnoreCase)
|
||||
|| AdditionalJsonMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static void ApplyMetadataHeaders(HttpResponse response, OpenApiDocumentSnapshot snapshot)
|
||||
{
|
||||
response.Headers[HeaderNames.ETag] = snapshot.ETag;
|
||||
response.Headers[HeaderNames.LastModified] = snapshot.LastWriteUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||
response.Headers[HeaderNames.CacheControl] = "public, max-age=300";
|
||||
response.Headers[HeaderNames.Vary] = "Accept";
|
||||
response.Headers["X-StellaOps-Service"] = snapshot.ServiceName;
|
||||
response.Headers["X-StellaOps-Api-Version"] = snapshot.ApiVersion;
|
||||
response.Headers["X-StellaOps-Build-Version"] = snapshot.BuildVersion;
|
||||
|
||||
if (snapshot.GrantTypes.Count > 0)
|
||||
{
|
||||
response.Headers["X-StellaOps-OAuth-Grants"] = string.Join(' ', snapshot.GrantTypes);
|
||||
}
|
||||
|
||||
if (snapshot.Scopes.Count > 0)
|
||||
{
|
||||
response.Headers["X-StellaOps-OAuth-Scopes"] = string.Join(' ', snapshot.Scopes);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesEtag(StringValues etagValues, string currentEtag)
|
||||
{
|
||||
if (etagValues.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var value in etagValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tokens = value.Split(',');
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var trimmed = token.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.Equals("*", StringComparison.Ordinal) || trimmed.Equals(currentEtag, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Authority.OpenApi;
|
||||
|
||||
internal static class OpenApiDiscoveryEndpointExtensions
|
||||
{
|
||||
private const string JsonMediaType = "application/openapi+json";
|
||||
private const string YamlMediaType = "application/openapi+yaml";
|
||||
private static readonly string[] AdditionalYamlMediaTypes = { "application/yaml", "text/yaml" };
|
||||
private static readonly string[] AdditionalJsonMediaTypes = { "application/json" };
|
||||
|
||||
public static IEndpointConventionBuilder MapAuthorityOpenApiDiscovery(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var builder = endpoints.MapGet("/.well-known/openapi", async (HttpContext context, [FromServices] AuthorityOpenApiDocumentProvider provider, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var snapshot = await provider.GetDocumentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var preferYaml = ShouldReturnYaml(context.Request.GetTypedHeaders().Accept);
|
||||
var payload = preferYaml ? snapshot.Yaml : snapshot.Json;
|
||||
var mediaType = preferYaml ? YamlMediaType : JsonMediaType;
|
||||
var contentType = string.Create(CultureInfo.InvariantCulture, $"{mediaType}; charset=utf-8");
|
||||
|
||||
ApplyMetadataHeaders(context.Response, snapshot);
|
||||
|
||||
if (MatchesEtag(context.Request.Headers[HeaderNames.IfNoneMatch], snapshot.ETag))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status304NotModified;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = contentType;
|
||||
await context.Response.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return builder.WithName("AuthorityOpenApiDiscovery");
|
||||
}
|
||||
|
||||
private static bool ShouldReturnYaml(IList<MediaTypeHeaderValue>? accept)
|
||||
{
|
||||
if (accept is null || accept.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ordered = accept
|
||||
.OrderByDescending(value => value.Quality ?? 1.0)
|
||||
.ThenByDescending(value => value.MediaType.HasValue && IsYaml(value.MediaType.Value));
|
||||
|
||||
foreach (var value in ordered)
|
||||
{
|
||||
if (!value.MediaType.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mediaType = value.MediaType.Value;
|
||||
if (IsYaml(mediaType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsJson(mediaType) || mediaType.Equals("*/*", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsYaml(string mediaType)
|
||||
=> mediaType.Equals(YamlMediaType, StringComparison.OrdinalIgnoreCase)
|
||||
|| AdditionalYamlMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsJson(string mediaType)
|
||||
=> mediaType.Equals(JsonMediaType, StringComparison.OrdinalIgnoreCase)
|
||||
|| AdditionalJsonMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static void ApplyMetadataHeaders(HttpResponse response, OpenApiDocumentSnapshot snapshot)
|
||||
{
|
||||
response.Headers[HeaderNames.ETag] = snapshot.ETag;
|
||||
response.Headers[HeaderNames.LastModified] = snapshot.LastWriteUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||
response.Headers[HeaderNames.CacheControl] = "public, max-age=300";
|
||||
response.Headers[HeaderNames.Vary] = "Accept";
|
||||
response.Headers["X-StellaOps-Service"] = snapshot.ServiceName;
|
||||
response.Headers["X-StellaOps-Api-Version"] = snapshot.ApiVersion;
|
||||
response.Headers["X-StellaOps-Build-Version"] = snapshot.BuildVersion;
|
||||
|
||||
if (snapshot.GrantTypes.Count > 0)
|
||||
{
|
||||
response.Headers["X-StellaOps-OAuth-Grants"] = string.Join(' ', snapshot.GrantTypes);
|
||||
}
|
||||
|
||||
if (snapshot.Scopes.Count > 0)
|
||||
{
|
||||
response.Headers["X-StellaOps-OAuth-Scopes"] = string.Join(' ', snapshot.Scopes);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesEtag(StringValues etagValues, string currentEtag)
|
||||
{
|
||||
if (etagValues.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var value in etagValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tokens = value.Split(',');
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var trimmed = token.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.Equals("*", StringComparison.Ordinal) || trimmed.Equals(currentEtag, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class AuthorityIdentityProviderSelector
|
||||
{
|
||||
public static ProviderSelectionResult ResolvePasswordProvider(OpenIddictRequest request, IAuthorityIdentityProviderRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
|
||||
if (registry.PasswordProviders.Count == 0)
|
||||
{
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.UnsupportedGrantType,
|
||||
"Password grants are not enabled because no identity providers support password authentication.");
|
||||
}
|
||||
|
||||
var providerName = request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
if (registry.PasswordProviders.Count == 1)
|
||||
{
|
||||
var provider = registry.PasswordProviders.First();
|
||||
return ProviderSelectionResult.Success(provider);
|
||||
}
|
||||
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.InvalidRequest,
|
||||
"identity_provider parameter is required when multiple password-capable providers are registered.");
|
||||
}
|
||||
|
||||
if (!registry.TryGet(providerName!, out var selected))
|
||||
{
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.InvalidRequest,
|
||||
$"Unknown identity provider '{providerName}'.");
|
||||
}
|
||||
|
||||
if (!selected.Capabilities.SupportsPassword)
|
||||
{
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.InvalidRequest,
|
||||
$"Identity provider '{providerName}' does not support password authentication.");
|
||||
}
|
||||
|
||||
return ProviderSelectionResult.Success(selected);
|
||||
}
|
||||
|
||||
internal sealed record ProviderSelectionResult(
|
||||
bool Succeeded,
|
||||
AuthorityIdentityProviderMetadata? Provider,
|
||||
string? Error,
|
||||
string? Description)
|
||||
{
|
||||
public static ProviderSelectionResult Success(AuthorityIdentityProviderMetadata provider)
|
||||
=> new(true, provider, null, null);
|
||||
|
||||
public static ProviderSelectionResult Failure(string error, string description)
|
||||
=> new(false, null, error, description);
|
||||
}
|
||||
}
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class AuthorityIdentityProviderSelector
|
||||
{
|
||||
public static ProviderSelectionResult ResolvePasswordProvider(OpenIddictRequest request, IAuthorityIdentityProviderRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
|
||||
if (registry.PasswordProviders.Count == 0)
|
||||
{
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.UnsupportedGrantType,
|
||||
"Password grants are not enabled because no identity providers support password authentication.");
|
||||
}
|
||||
|
||||
var providerName = request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
if (registry.PasswordProviders.Count == 1)
|
||||
{
|
||||
var provider = registry.PasswordProviders.First();
|
||||
return ProviderSelectionResult.Success(provider);
|
||||
}
|
||||
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.InvalidRequest,
|
||||
"identity_provider parameter is required when multiple password-capable providers are registered.");
|
||||
}
|
||||
|
||||
if (!registry.TryGet(providerName!, out var selected))
|
||||
{
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.InvalidRequest,
|
||||
$"Unknown identity provider '{providerName}'.");
|
||||
}
|
||||
|
||||
if (!selected.Capabilities.SupportsPassword)
|
||||
{
|
||||
return ProviderSelectionResult.Failure(
|
||||
OpenIddictConstants.Errors.InvalidRequest,
|
||||
$"Identity provider '{providerName}' does not support password authentication.");
|
||||
}
|
||||
|
||||
return ProviderSelectionResult.Success(selected);
|
||||
}
|
||||
|
||||
internal sealed record ProviderSelectionResult(
|
||||
bool Succeeded,
|
||||
AuthorityIdentityProviderMetadata? Provider,
|
||||
string? Error,
|
||||
string? Description)
|
||||
{
|
||||
public static ProviderSelectionResult Success(AuthorityIdentityProviderMetadata provider)
|
||||
=> new(true, provider, null, null);
|
||||
|
||||
public static ProviderSelectionResult Failure(string error, string description)
|
||||
=> new(false, null, error, description);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,269 +1,269 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal static class ClientCredentialsAuditHelper
|
||||
{
|
||||
internal static string EnsureCorrelationId(OpenIddictServerTransaction transaction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditCorrelationProperty, out var value) &&
|
||||
value is string existing &&
|
||||
!string.IsNullOrWhiteSpace(existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var correlation = Activity.Current?.TraceId.ToString() ??
|
||||
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
transaction.Properties[AuthorityOpenIddictConstants.AuditCorrelationProperty] = correlation;
|
||||
return correlation;
|
||||
}
|
||||
|
||||
internal static AuthEventRecord CreateRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
string? clientSecret,
|
||||
AuthEventOutcome outcome,
|
||||
string? reason,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? tenant,
|
||||
string? project,
|
||||
bool? confidential,
|
||||
IReadOnlyList<string> requestedScopes,
|
||||
IReadOnlyList<string> grantedScopes,
|
||||
string? invalidScope,
|
||||
IEnumerable<AuthEventProperty>? extraProperties = null,
|
||||
string? eventType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
var correlationId = EnsureCorrelationId(transaction);
|
||||
var client = BuildClient(clientId, providerName);
|
||||
var network = BuildNetwork(metadata);
|
||||
var normalizedGranted = NormalizeScopes(grantedScopes);
|
||||
var properties = BuildProperties(confidential, requestedScopes, invalidScope, extraProperties);
|
||||
var normalizedTenant = NormalizeTenant(tenant);
|
||||
var normalizedProject = NormalizeProject(project);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.client_credentials.grant" : eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = Normalize(reason),
|
||||
Subject = null,
|
||||
Client = client,
|
||||
Scopes = normalizedGranted,
|
||||
Network = network,
|
||||
Tenant = ClassifiedString.Public(normalizedTenant),
|
||||
Project = ClassifiedString.Public(normalizedProject),
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
|
||||
internal static AuthEventRecord CreateTamperRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? tenant,
|
||||
string? project,
|
||||
bool? confidential,
|
||||
IEnumerable<string> unexpectedParameters)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "request.tampered",
|
||||
Value = ClassifiedString.Public("true")
|
||||
}
|
||||
};
|
||||
|
||||
if (confidential.HasValue)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "client.confidential",
|
||||
Value = ClassifiedString.Public(confidential.Value ? "true" : "false")
|
||||
});
|
||||
}
|
||||
|
||||
if (unexpectedParameters is not null)
|
||||
{
|
||||
foreach (var parameter in unexpectedParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameter))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "request.unexpected_parameter",
|
||||
Value = ClassifiedString.Public(parameter)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var reason = unexpectedParameters is null
|
||||
? "Unexpected parameters supplied to client credentials request."
|
||||
: $"Unexpected parameters supplied to client credentials request: {string.Join(", ", unexpectedParameters)}.";
|
||||
|
||||
return CreateRecord(
|
||||
timeProvider,
|
||||
transaction,
|
||||
metadata,
|
||||
clientSecret: null,
|
||||
outcome: AuthEventOutcome.Failure,
|
||||
reason: reason,
|
||||
clientId: clientId,
|
||||
providerName: providerName,
|
||||
tenant: tenant,
|
||||
project: project,
|
||||
confidential: confidential,
|
||||
requestedScopes: Array.Empty<string>(),
|
||||
grantedScopes: Array.Empty<string>(),
|
||||
invalidScope: null,
|
||||
extraProperties: properties,
|
||||
eventType: "authority.token.tamper");
|
||||
}
|
||||
|
||||
private static AuthEventClient? BuildClient(string? clientId, string? providerName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId) && string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(Normalize(clientId)),
|
||||
Name = ClassifiedString.Empty,
|
||||
Provider = ClassifiedString.Public(Normalize(providerName))
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(AuthorityRateLimiterMetadata? metadata)
|
||||
{
|
||||
var remote = Normalize(metadata?.RemoteIp);
|
||||
var forwarded = Normalize(metadata?.ForwardedFor);
|
||||
var userAgent = Normalize(metadata?.UserAgent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded) && string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remote),
|
||||
ForwardedFor = ClassifiedString.Personal(forwarded),
|
||||
UserAgent = ClassifiedString.Personal(userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(
|
||||
bool? confidential,
|
||||
IReadOnlyList<string> requestedScopes,
|
||||
string? invalidScope,
|
||||
IEnumerable<AuthEventProperty>? extraProperties)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
if (confidential.HasValue)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "client.confidential",
|
||||
Value = ClassifiedString.Public(confidential.Value ? "true" : "false")
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedRequested = NormalizeScopes(requestedScopes);
|
||||
if (normalizedRequested is { Count: > 0 })
|
||||
{
|
||||
foreach (var scope in normalizedRequested)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "scope.requested",
|
||||
Value = ClassifiedString.Public(scope)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invalidScope))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "scope.invalid",
|
||||
Value = ClassifiedString.Public(invalidScope)
|
||||
});
|
||||
}
|
||||
|
||||
if (extraProperties is not null)
|
||||
{
|
||||
foreach (var property in extraProperties)
|
||||
{
|
||||
if (property is null || string.IsNullOrWhiteSpace(property.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeScopes(IReadOnlyList<string>? scopes)
|
||||
{
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = scopes
|
||||
.Where(static scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(static scope => scope.Trim())
|
||||
.Where(static scope => scope.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static scope => scope, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return normalized.Length == 0 ? Array.Empty<string>() : normalized;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string NormalizeProject(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? StellaOpsTenancyDefaults.AnyProject : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal static class ClientCredentialsAuditHelper
|
||||
{
|
||||
internal static string EnsureCorrelationId(OpenIddictServerTransaction transaction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditCorrelationProperty, out var value) &&
|
||||
value is string existing &&
|
||||
!string.IsNullOrWhiteSpace(existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var correlation = Activity.Current?.TraceId.ToString() ??
|
||||
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
transaction.Properties[AuthorityOpenIddictConstants.AuditCorrelationProperty] = correlation;
|
||||
return correlation;
|
||||
}
|
||||
|
||||
internal static AuthEventRecord CreateRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
string? clientSecret,
|
||||
AuthEventOutcome outcome,
|
||||
string? reason,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? tenant,
|
||||
string? project,
|
||||
bool? confidential,
|
||||
IReadOnlyList<string> requestedScopes,
|
||||
IReadOnlyList<string> grantedScopes,
|
||||
string? invalidScope,
|
||||
IEnumerable<AuthEventProperty>? extraProperties = null,
|
||||
string? eventType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
var correlationId = EnsureCorrelationId(transaction);
|
||||
var client = BuildClient(clientId, providerName);
|
||||
var network = BuildNetwork(metadata);
|
||||
var normalizedGranted = NormalizeScopes(grantedScopes);
|
||||
var properties = BuildProperties(confidential, requestedScopes, invalidScope, extraProperties);
|
||||
var normalizedTenant = NormalizeTenant(tenant);
|
||||
var normalizedProject = NormalizeProject(project);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.client_credentials.grant" : eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = Normalize(reason),
|
||||
Subject = null,
|
||||
Client = client,
|
||||
Scopes = normalizedGranted,
|
||||
Network = network,
|
||||
Tenant = ClassifiedString.Public(normalizedTenant),
|
||||
Project = ClassifiedString.Public(normalizedProject),
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
|
||||
internal static AuthEventRecord CreateTamperRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? tenant,
|
||||
string? project,
|
||||
bool? confidential,
|
||||
IEnumerable<string> unexpectedParameters)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "request.tampered",
|
||||
Value = ClassifiedString.Public("true")
|
||||
}
|
||||
};
|
||||
|
||||
if (confidential.HasValue)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "client.confidential",
|
||||
Value = ClassifiedString.Public(confidential.Value ? "true" : "false")
|
||||
});
|
||||
}
|
||||
|
||||
if (unexpectedParameters is not null)
|
||||
{
|
||||
foreach (var parameter in unexpectedParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameter))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "request.unexpected_parameter",
|
||||
Value = ClassifiedString.Public(parameter)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var reason = unexpectedParameters is null
|
||||
? "Unexpected parameters supplied to client credentials request."
|
||||
: $"Unexpected parameters supplied to client credentials request: {string.Join(", ", unexpectedParameters)}.";
|
||||
|
||||
return CreateRecord(
|
||||
timeProvider,
|
||||
transaction,
|
||||
metadata,
|
||||
clientSecret: null,
|
||||
outcome: AuthEventOutcome.Failure,
|
||||
reason: reason,
|
||||
clientId: clientId,
|
||||
providerName: providerName,
|
||||
tenant: tenant,
|
||||
project: project,
|
||||
confidential: confidential,
|
||||
requestedScopes: Array.Empty<string>(),
|
||||
grantedScopes: Array.Empty<string>(),
|
||||
invalidScope: null,
|
||||
extraProperties: properties,
|
||||
eventType: "authority.token.tamper");
|
||||
}
|
||||
|
||||
private static AuthEventClient? BuildClient(string? clientId, string? providerName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId) && string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(Normalize(clientId)),
|
||||
Name = ClassifiedString.Empty,
|
||||
Provider = ClassifiedString.Public(Normalize(providerName))
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(AuthorityRateLimiterMetadata? metadata)
|
||||
{
|
||||
var remote = Normalize(metadata?.RemoteIp);
|
||||
var forwarded = Normalize(metadata?.ForwardedFor);
|
||||
var userAgent = Normalize(metadata?.UserAgent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded) && string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remote),
|
||||
ForwardedFor = ClassifiedString.Personal(forwarded),
|
||||
UserAgent = ClassifiedString.Personal(userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(
|
||||
bool? confidential,
|
||||
IReadOnlyList<string> requestedScopes,
|
||||
string? invalidScope,
|
||||
IEnumerable<AuthEventProperty>? extraProperties)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
if (confidential.HasValue)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "client.confidential",
|
||||
Value = ClassifiedString.Public(confidential.Value ? "true" : "false")
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedRequested = NormalizeScopes(requestedScopes);
|
||||
if (normalizedRequested is { Count: > 0 })
|
||||
{
|
||||
foreach (var scope in normalizedRequested)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "scope.requested",
|
||||
Value = ClassifiedString.Public(scope)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invalidScope))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "scope.invalid",
|
||||
Value = ClassifiedString.Public(invalidScope)
|
||||
});
|
||||
}
|
||||
|
||||
if (extraProperties is not null)
|
||||
{
|
||||
foreach (var property in extraProperties)
|
||||
{
|
||||
if (property is null || string.IsNullOrWhiteSpace(property.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeScopes(IReadOnlyList<string>? scopes)
|
||||
{
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = scopes
|
||||
.Where(static scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(static scope => scope.Trim())
|
||||
.Where(static scope => scope.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static scope => scope, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return normalized.Length == 0 ? Array.Empty<string>() : normalized;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string NormalizeProject(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? StellaOpsTenancyDefaults.AnyProject : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Airgap;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
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.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Configuration;
|
||||
@@ -1522,7 +1522,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
{
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
|
||||
private readonly IAuthoritySessionAccessor sessionAccessor;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ActivitySource activitySource;
|
||||
@@ -1531,7 +1531,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
public HandleClientCredentialsHandler(
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
IAuthoritySessionAccessor sessionAccessor,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
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 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;
|
||||
@@ -17,16 +17,16 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
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.Cryptography.Audit;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
|
||||
{
|
||||
private const string AnyDpopKeyThumbprint = "__authority_any_dpop_key__";
|
||||
@@ -41,11 +41,11 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly Counter<long> dpopNonceMissCounter;
|
||||
private readonly ILogger<ValidateDpopProofHandler> logger;
|
||||
|
||||
public ValidateDpopProofHandler(
|
||||
StellaOpsAuthorityOptions authorityOptions,
|
||||
IAuthorityClientStore clientStore,
|
||||
IDpopProofValidator proofValidator,
|
||||
|
||||
public ValidateDpopProofHandler(
|
||||
StellaOpsAuthorityOptions authorityOptions,
|
||||
IAuthorityClientStore clientStore,
|
||||
IDpopProofValidator proofValidator,
|
||||
IDpopNonceStore nonceStore,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
IAuthEventSink auditSink,
|
||||
@@ -53,12 +53,12 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
ActivitySource activitySource,
|
||||
Meter meter,
|
||||
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.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));
|
||||
@@ -70,12 +70,12 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
name: "authority_dpop_nonce_miss_total",
|
||||
description: "Count of DPoP nonce challenges due to missing or invalid proofs.");
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var request = context.Request;
|
||||
if (request is null)
|
||||
{
|
||||
@@ -94,17 +94,17 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
var configuredAudiences = EnsureRequestAudiences(request, clientDocument);
|
||||
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
|
||||
@@ -133,7 +133,7 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
{
|
||||
logger.LogDebug("DPoP enforcement enabled for client {ClientId} targeting audience {Audience}.", clientId, matchedNonceAudience);
|
||||
}
|
||||
|
||||
|
||||
if (!senderConstraintOptions.Dpop.Enabled)
|
||||
{
|
||||
logger.LogError("Client {ClientId} requires DPoP but server-side configuration has DPoP disabled.", clientId);
|
||||
@@ -163,23 +163,23 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
@@ -192,19 +192,19 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
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)
|
||||
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."
|
||||
@@ -222,7 +222,7 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
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);
|
||||
@@ -235,193 +235,193 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
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;
|
||||
}
|
||||
|
||||
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 requiredAudience = matchedNonceAudience;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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 ConsumeNonceAsync(
|
||||
suppliedNonce,
|
||||
requiredAudience,
|
||||
clientDocument,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
@@ -495,7 +495,7 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private async ValueTask ChallengeNonceAsync(
|
||||
OpenIddictServerEvents.ValidateTokenRequestContext context,
|
||||
AuthorityClientDocument clientDocument,
|
||||
@@ -509,7 +509,7 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
RecordNonceMiss(reasonCode);
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, description);
|
||||
metadataAccessor.SetTag("authority.dpop_result", reasonCode);
|
||||
|
||||
|
||||
string? issuedNonce = null;
|
||||
DateTimeOffset? expiresAt = null;
|
||||
var nonceOptions = senderConstraintOptions.Dpop.Nonce;
|
||||
@@ -529,25 +529,25 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -599,129 +599,129 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -756,7 +756,7 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
|
||||
invalidScope: null,
|
||||
extraProperties: properties,
|
||||
eventType: eventType);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ using StellaOps.Authority.Airgap;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
@@ -11,8 +11,8 @@ using OpenIddict.Server;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Airgap;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
|
||||
@@ -1,142 +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.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);
|
||||
}
|
||||
}
|
||||
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.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleRevocationRequestContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthoritySessionAccessor sessionAccessor;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<HandleRevocationRequestHandler> logger;
|
||||
private readonly ActivitySource activitySource;
|
||||
|
||||
public HandleRevocationRequestHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthoritySessionAccessor 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
@@ -21,14 +21,14 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
|
||||
private readonly IAuthoritySessionAccessor sessionAccessor;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly ILogger<PersistTokensHandler> logger;
|
||||
|
||||
public PersistTokensHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
IAuthoritySessionAccessor sessionAccessor,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<PersistTokensHandler> logger)
|
||||
|
||||
@@ -15,9 +15,9 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Authority.Security;
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
|
||||
private readonly IAuthoritySessionAccessor sessionAccessor;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
@@ -40,7 +40,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
|
||||
public ValidateAccessTokenHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
IAuthoritySessionAccessor sessionAccessor,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class TokenRequestTamperInspector
|
||||
{
|
||||
private static readonly HashSet<string> CommonParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenIddictConstants.Parameters.GrantType,
|
||||
OpenIddictConstants.Parameters.Scope,
|
||||
OpenIddictConstants.Parameters.Resource,
|
||||
OpenIddictConstants.Parameters.ClientId,
|
||||
OpenIddictConstants.Parameters.ClientSecret,
|
||||
OpenIddictConstants.Parameters.ClientAssertion,
|
||||
OpenIddictConstants.Parameters.ClientAssertionType,
|
||||
OpenIddictConstants.Parameters.RefreshToken,
|
||||
OpenIddictConstants.Parameters.DeviceCode,
|
||||
OpenIddictConstants.Parameters.Code,
|
||||
OpenIddictConstants.Parameters.CodeVerifier,
|
||||
OpenIddictConstants.Parameters.CodeChallenge,
|
||||
OpenIddictConstants.Parameters.CodeChallengeMethod,
|
||||
OpenIddictConstants.Parameters.RedirectUri,
|
||||
OpenIddictConstants.Parameters.Assertion,
|
||||
OpenIddictConstants.Parameters.Nonce,
|
||||
OpenIddictConstants.Parameters.Prompt,
|
||||
OpenIddictConstants.Parameters.MaxAge,
|
||||
OpenIddictConstants.Parameters.UiLocales,
|
||||
OpenIddictConstants.Parameters.AcrValues,
|
||||
OpenIddictConstants.Parameters.LoginHint,
|
||||
OpenIddictConstants.Parameters.Claims,
|
||||
OpenIddictConstants.Parameters.Token,
|
||||
OpenIddictConstants.Parameters.TokenTypeHint,
|
||||
OpenIddictConstants.Parameters.AccessToken,
|
||||
OpenIddictConstants.Parameters.IdToken
|
||||
};
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class TokenRequestTamperInspector
|
||||
{
|
||||
private static readonly HashSet<string> CommonParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenIddictConstants.Parameters.GrantType,
|
||||
OpenIddictConstants.Parameters.Scope,
|
||||
OpenIddictConstants.Parameters.Resource,
|
||||
OpenIddictConstants.Parameters.ClientId,
|
||||
OpenIddictConstants.Parameters.ClientSecret,
|
||||
OpenIddictConstants.Parameters.ClientAssertion,
|
||||
OpenIddictConstants.Parameters.ClientAssertionType,
|
||||
OpenIddictConstants.Parameters.RefreshToken,
|
||||
OpenIddictConstants.Parameters.DeviceCode,
|
||||
OpenIddictConstants.Parameters.Code,
|
||||
OpenIddictConstants.Parameters.CodeVerifier,
|
||||
OpenIddictConstants.Parameters.CodeChallenge,
|
||||
OpenIddictConstants.Parameters.CodeChallengeMethod,
|
||||
OpenIddictConstants.Parameters.RedirectUri,
|
||||
OpenIddictConstants.Parameters.Assertion,
|
||||
OpenIddictConstants.Parameters.Nonce,
|
||||
OpenIddictConstants.Parameters.Prompt,
|
||||
OpenIddictConstants.Parameters.MaxAge,
|
||||
OpenIddictConstants.Parameters.UiLocales,
|
||||
OpenIddictConstants.Parameters.AcrValues,
|
||||
OpenIddictConstants.Parameters.LoginHint,
|
||||
OpenIddictConstants.Parameters.Claims,
|
||||
OpenIddictConstants.Parameters.Token,
|
||||
OpenIddictConstants.Parameters.TokenTypeHint,
|
||||
OpenIddictConstants.Parameters.AccessToken,
|
||||
OpenIddictConstants.Parameters.IdToken
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> PasswordGrantParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenIddictConstants.Parameters.Username,
|
||||
@@ -48,7 +48,7 @@ internal static class TokenRequestTamperInspector
|
||||
AuthorityOpenIddictConstants.PolicyTicketParameterName,
|
||||
AuthorityOpenIddictConstants.PolicyDigestParameterName
|
||||
};
|
||||
|
||||
|
||||
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
AuthorityOpenIddictConstants.ProviderParameterName,
|
||||
@@ -62,66 +62,66 @@ internal static class TokenRequestTamperInspector
|
||||
AuthorityOpenIddictConstants.VulnOwnerParameterName,
|
||||
AuthorityOpenIddictConstants.VulnBusinessTierParameterName
|
||||
};
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)
|
||||
=> DetectUnexpectedParameters(request, PasswordGrantParameters);
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedClientCredentialsParameters(OpenIddictRequest request)
|
||||
=> DetectUnexpectedParameters(request, ClientCredentialsParameters);
|
||||
|
||||
private static IReadOnlyList<string> DetectUnexpectedParameters(
|
||||
OpenIddictRequest request,
|
||||
HashSet<string> grantSpecific)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unexpected = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var pair in request.GetParameters())
|
||||
{
|
||||
var name = pair.Key;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsAllowed(name, grantSpecific))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
unexpected.Add(name);
|
||||
}
|
||||
|
||||
return unexpected.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: unexpected
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string parameterName, HashSet<string> grantSpecific)
|
||||
{
|
||||
if (CommonParameters.Contains(parameterName) || grantSpecific.Contains(parameterName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parameterName.StartsWith("ext_", StringComparison.OrdinalIgnoreCase) ||
|
||||
parameterName.StartsWith("x-", StringComparison.OrdinalIgnoreCase) ||
|
||||
parameterName.StartsWith("custom_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parameterName.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)
|
||||
=> DetectUnexpectedParameters(request, PasswordGrantParameters);
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedClientCredentialsParameters(OpenIddictRequest request)
|
||||
=> DetectUnexpectedParameters(request, ClientCredentialsParameters);
|
||||
|
||||
private static IReadOnlyList<string> DetectUnexpectedParameters(
|
||||
OpenIddictRequest request,
|
||||
HashSet<string> grantSpecific)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unexpected = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var pair in request.GetParameters())
|
||||
{
|
||||
var name = pair.Key;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsAllowed(name, grantSpecific))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
unexpected.Add(name);
|
||||
}
|
||||
|
||||
return unexpected.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: unexpected
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string parameterName, HashSet<string> grantSpecific)
|
||||
{
|
||||
if (CommonParameters.Contains(parameterName) || grantSpecific.Contains(parameterName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parameterName.StartsWith("ext_", StringComparison.OrdinalIgnoreCase) ||
|
||||
parameterName.StartsWith("x-", StringComparison.OrdinalIgnoreCase) ||
|
||||
parameterName.StartsWith("custom_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parameterName.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Permalinks;
|
||||
|
||||
public sealed record VulnPermalinkRequest(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("resourceKind")] string ResourceKind,
|
||||
[property: JsonPropertyName("state")] JsonElement State,
|
||||
[property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds,
|
||||
[property: JsonPropertyName("environment")] string? Environment);
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Permalinks;
|
||||
|
||||
public sealed record VulnPermalinkRequest(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("resourceKind")] string ResourceKind,
|
||||
[property: JsonPropertyName("state")] JsonElement State,
|
||||
[property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds,
|
||||
[property: JsonPropertyName("environment")] string? Environment);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Permalinks;
|
||||
|
||||
public sealed record VulnPermalinkResponse(
|
||||
[property: JsonPropertyName("token")] string Token,
|
||||
[property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt,
|
||||
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Permalinks;
|
||||
|
||||
public sealed record VulnPermalinkResponse(
|
||||
[property: JsonPropertyName("token")] string Token,
|
||||
[property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt,
|
||||
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
|
||||
|
||||
@@ -1,128 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Permalinks;
|
||||
|
||||
internal sealed class VulnPermalinkService
|
||||
{
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan MaxLifetime = TimeSpan.FromDays(30);
|
||||
private const int MaxStateBytes = 8 * 1024;
|
||||
|
||||
private readonly ICryptoProviderRegistry providerRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptions;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<VulnPermalinkService> logger;
|
||||
|
||||
public VulnPermalinkService(
|
||||
ICryptoProviderRegistry providerRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VulnPermalinkService> logger)
|
||||
{
|
||||
this.providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry));
|
||||
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 async Task<VulnPermalinkResponse> CreateAsync(VulnPermalinkRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var tenant = request.Tenant?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant is required.", nameof(request));
|
||||
}
|
||||
|
||||
var resourceKind = request.ResourceKind?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(resourceKind))
|
||||
{
|
||||
throw new ArgumentException("Resource kind is required.", nameof(request));
|
||||
}
|
||||
|
||||
var stateJson = request.State.ValueKind == JsonValueKind.Undefined
|
||||
? "{}"
|
||||
: request.State.GetRawText();
|
||||
|
||||
if (Encoding.UTF8.GetByteCount(stateJson) > MaxStateBytes)
|
||||
{
|
||||
throw new ArgumentException("State payload exceeds 8 KB limit.", nameof(request));
|
||||
}
|
||||
|
||||
JsonElement stateElement;
|
||||
using (var stateDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(stateJson) ? "{}" : stateJson))
|
||||
{
|
||||
stateElement = stateDocument.RootElement.Clone();
|
||||
}
|
||||
|
||||
var lifetime = request.ExpiresInSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(request.ExpiresInSeconds.Value)
|
||||
: DefaultLifetime;
|
||||
|
||||
if (lifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentException("Expiration must be positive.", nameof(request));
|
||||
}
|
||||
|
||||
if (lifetime > MaxLifetime)
|
||||
{
|
||||
lifetime = MaxLifetime;
|
||||
}
|
||||
|
||||
var signing = authorityOptions.Value.Signing
|
||||
?? throw new InvalidOperationException("Authority signing configuration is required to issue permalinks.");
|
||||
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing to issue permalinks.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.ActiveKeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing configuration requires an active key identifier.");
|
||||
}
|
||||
|
||||
var algorithm = string.IsNullOrWhiteSpace(signing.Algorithm)
|
||||
? SignatureAlgorithms.Es256
|
||||
: signing.Algorithm.Trim();
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
var expiresAt = issuedAt.Add(lifetime);
|
||||
|
||||
var keyReference = new CryptoKeyReference(signing.ActiveKeyId, signing.Provider);
|
||||
var resolution = providerRegistry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
algorithm,
|
||||
keyReference,
|
||||
signing.Provider);
|
||||
var signer = resolution.Signer;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Permalinks;
|
||||
|
||||
internal sealed class VulnPermalinkService
|
||||
{
|
||||
private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan MaxLifetime = TimeSpan.FromDays(30);
|
||||
private const int MaxStateBytes = 8 * 1024;
|
||||
|
||||
private readonly ICryptoProviderRegistry providerRegistry;
|
||||
private readonly IOptions<StellaOpsAuthorityOptions> authorityOptions;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<VulnPermalinkService> logger;
|
||||
|
||||
public VulnPermalinkService(
|
||||
ICryptoProviderRegistry providerRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VulnPermalinkService> logger)
|
||||
{
|
||||
this.providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry));
|
||||
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 async Task<VulnPermalinkResponse> CreateAsync(VulnPermalinkRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var tenant = request.Tenant?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant is required.", nameof(request));
|
||||
}
|
||||
|
||||
var resourceKind = request.ResourceKind?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(resourceKind))
|
||||
{
|
||||
throw new ArgumentException("Resource kind is required.", nameof(request));
|
||||
}
|
||||
|
||||
var stateJson = request.State.ValueKind == JsonValueKind.Undefined
|
||||
? "{}"
|
||||
: request.State.GetRawText();
|
||||
|
||||
if (Encoding.UTF8.GetByteCount(stateJson) > MaxStateBytes)
|
||||
{
|
||||
throw new ArgumentException("State payload exceeds 8 KB limit.", nameof(request));
|
||||
}
|
||||
|
||||
JsonElement stateElement;
|
||||
using (var stateDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(stateJson) ? "{}" : stateJson))
|
||||
{
|
||||
stateElement = stateDocument.RootElement.Clone();
|
||||
}
|
||||
|
||||
var lifetime = request.ExpiresInSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(request.ExpiresInSeconds.Value)
|
||||
: DefaultLifetime;
|
||||
|
||||
if (lifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentException("Expiration must be positive.", nameof(request));
|
||||
}
|
||||
|
||||
if (lifetime > MaxLifetime)
|
||||
{
|
||||
lifetime = MaxLifetime;
|
||||
}
|
||||
|
||||
var signing = authorityOptions.Value.Signing
|
||||
?? throw new InvalidOperationException("Authority signing configuration is required to issue permalinks.");
|
||||
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing is disabled. Enable signing to issue permalinks.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signing.ActiveKeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing configuration requires an active key identifier.");
|
||||
}
|
||||
|
||||
var algorithm = string.IsNullOrWhiteSpace(signing.Algorithm)
|
||||
? SignatureAlgorithms.Es256
|
||||
: signing.Algorithm.Trim();
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
var expiresAt = issuedAt.Add(lifetime);
|
||||
|
||||
var keyReference = new CryptoKeyReference(signing.ActiveKeyId, signing.Provider);
|
||||
var resolution = providerRegistry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
algorithm,
|
||||
keyReference,
|
||||
signing.Provider);
|
||||
var signer = resolution.Signer;
|
||||
|
||||
var scopes = new[]
|
||||
{
|
||||
StellaOpsScopes.VulnView,
|
||||
@@ -143,47 +143,47 @@ internal sealed class VulnPermalinkService
|
||||
ExpiresAt: expiresAt.ToUnixTimeSeconds(),
|
||||
TokenId: Guid.NewGuid().ToString("N"),
|
||||
Resource: new VulnPermalinkResource(resourceKind, stateElement));
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions);
|
||||
var header = new Dictionary<string, object>
|
||||
{
|
||||
["alg"] = algorithm,
|
||||
["typ"] = "JWT",
|
||||
["kid"] = signer.KeyId
|
||||
};
|
||||
|
||||
var headerBytes = JsonSerializer.SerializeToUtf8Bytes(header, HeaderSerializerOptions);
|
||||
var encodedHeader = Base64UrlEncoder.Encode(headerBytes);
|
||||
var encodedPayload = Base64UrlEncoder.Encode(payloadBytes);
|
||||
|
||||
var signingInput = Encoding.ASCII.GetBytes(string.Concat(encodedHeader, '.', encodedPayload));
|
||||
var signatureBytes = await signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false);
|
||||
var encodedSignature = Base64UrlEncoder.Encode(signatureBytes);
|
||||
var token = string.Concat(encodedHeader, '.', encodedPayload, '.', encodedSignature);
|
||||
|
||||
logger.LogDebug("Issued Vuln Explorer permalink for tenant {Tenant} with resource kind {Resource}.", tenant, resourceKind);
|
||||
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions);
|
||||
var header = new Dictionary<string, object>
|
||||
{
|
||||
["alg"] = algorithm,
|
||||
["typ"] = "JWT",
|
||||
["kid"] = signer.KeyId
|
||||
};
|
||||
|
||||
var headerBytes = JsonSerializer.SerializeToUtf8Bytes(header, HeaderSerializerOptions);
|
||||
var encodedHeader = Base64UrlEncoder.Encode(headerBytes);
|
||||
var encodedPayload = Base64UrlEncoder.Encode(payloadBytes);
|
||||
|
||||
var signingInput = Encoding.ASCII.GetBytes(string.Concat(encodedHeader, '.', encodedPayload));
|
||||
var signatureBytes = await signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false);
|
||||
var encodedSignature = Base64UrlEncoder.Encode(signatureBytes);
|
||||
var token = string.Concat(encodedHeader, '.', encodedPayload, '.', encodedSignature);
|
||||
|
||||
logger.LogDebug("Issued Vuln Explorer permalink for tenant {Tenant} with resource kind {Resource}.", tenant, resourceKind);
|
||||
|
||||
return new VulnPermalinkResponse(
|
||||
Token: token,
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Scopes: scopes);
|
||||
}
|
||||
|
||||
private sealed record VulnPermalinkPayload(
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
[property: JsonPropertyName("aud")] string Audience,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("environment")] string? Environment,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
|
||||
[property: JsonPropertyName("iat")] long IssuedAt,
|
||||
[property: JsonPropertyName("nbf")] long NotBefore,
|
||||
[property: JsonPropertyName("exp")] long ExpiresAt,
|
||||
[property: JsonPropertyName("jti")] string TokenId,
|
||||
[property: JsonPropertyName("resource")] VulnPermalinkResource Resource);
|
||||
|
||||
private sealed record VulnPermalinkResource(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("state")] JsonElement State);
|
||||
}
|
||||
|
||||
private sealed record VulnPermalinkPayload(
|
||||
[property: JsonPropertyName("sub")] string Subject,
|
||||
[property: JsonPropertyName("aud")] string Audience,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("environment")] string? Environment,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes,
|
||||
[property: JsonPropertyName("iat")] long IssuedAt,
|
||||
[property: JsonPropertyName("nbf")] long NotBefore,
|
||||
[property: JsonPropertyName("exp")] long ExpiresAt,
|
||||
[property: JsonPropertyName("jti")] string TokenId,
|
||||
[property: JsonPropertyName("resource")] VulnPermalinkResource Resource);
|
||||
|
||||
private sealed record VulnPermalinkResource(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("state")] JsonElement State);
|
||||
}
|
||||
|
||||
@@ -1,342 +1,342 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Authority.Plugins;
|
||||
|
||||
internal static class AuthorityPluginLoader
|
||||
{
|
||||
public static AuthorityPluginRegistrationSummary RegisterPlugins(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
PluginHostOptions hostOptions,
|
||||
IReadOnlyCollection<AuthorityPluginContext> pluginContexts,
|
||||
ILogger? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(hostOptions);
|
||||
ArgumentNullException.ThrowIfNull(pluginContexts);
|
||||
|
||||
if (pluginContexts.Count == 0)
|
||||
{
|
||||
return AuthorityPluginRegistrationSummary.Empty;
|
||||
}
|
||||
|
||||
var loadResult = PluginHost.LoadPlugins(hostOptions, logger);
|
||||
var descriptors = loadResult.Plugins
|
||||
.Select(p => new LoadedPluginDescriptor(p.Assembly, p.AssemblyPath))
|
||||
.ToArray();
|
||||
|
||||
return RegisterPluginsCore(
|
||||
services,
|
||||
configuration,
|
||||
pluginContexts,
|
||||
descriptors,
|
||||
loadResult.MissingOrderedPlugins,
|
||||
logger);
|
||||
}
|
||||
|
||||
internal static AuthorityPluginRegistrationSummary RegisterPluginsCore(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IReadOnlyCollection<AuthorityPluginContext> pluginContexts,
|
||||
IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies,
|
||||
IReadOnlyCollection<string> missingOrdered,
|
||||
ILogger? logger)
|
||||
{
|
||||
var registrarCandidates = DiscoverRegistrars(loadedAssemblies);
|
||||
var pluginTypeLookup = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
|
||||
var registrarTypeLookup = new Dictionary<Type, string>();
|
||||
var registered = new List<string>();
|
||||
var failures = new List<AuthorityPluginRegistrationFailure>();
|
||||
|
||||
foreach (var pluginContext in pluginContexts)
|
||||
{
|
||||
var manifest = pluginContext.Manifest;
|
||||
|
||||
if (!manifest.Enabled)
|
||||
{
|
||||
logger?.LogInformation(
|
||||
"Skipping disabled Authority plugin '{PluginName}' ({PluginType}).",
|
||||
manifest.Name,
|
||||
manifest.Type);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsAssemblyLoaded(manifest, loadedAssemblies))
|
||||
{
|
||||
var reason = $"Assembly '{manifest.AssemblyName ?? manifest.AssemblyPath ?? manifest.Type}' was not loaded.";
|
||||
logger?.LogError(
|
||||
"Failed to register Authority plugin '{PluginName}': {Reason}",
|
||||
manifest.Name,
|
||||
reason);
|
||||
failures.Add(new AuthorityPluginRegistrationFailure(manifest.Name, reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
var activation = TryResolveActivationForManifest(
|
||||
services,
|
||||
manifest.Type,
|
||||
registrarCandidates,
|
||||
pluginTypeLookup,
|
||||
registrarTypeLookup,
|
||||
logger,
|
||||
out var registrarType);
|
||||
|
||||
if (activation is null || registrarType is null)
|
||||
{
|
||||
var reason = $"No registrar found for plugin type '{manifest.Type}'.";
|
||||
logger?.LogError(
|
||||
"Failed to register Authority plugin '{PluginName}': {Reason}",
|
||||
manifest.Name,
|
||||
reason);
|
||||
failures.Add(new AuthorityPluginRegistrationFailure(manifest.Name, reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(services, registrarType.Assembly, logger);
|
||||
|
||||
activation.Registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
registered.Add(manifest.Name);
|
||||
|
||||
logger?.LogInformation(
|
||||
"Registered Authority plugin '{PluginName}' ({PluginType}).",
|
||||
manifest.Name,
|
||||
manifest.Type);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var reason = $"Registration threw {ex.GetType().Name}.";
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to register Authority plugin '{PluginName}'.",
|
||||
manifest.Name);
|
||||
failures.Add(new AuthorityPluginRegistrationFailure(manifest.Name, reason));
|
||||
}
|
||||
finally
|
||||
{
|
||||
activation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (missingOrdered.Count > 0)
|
||||
{
|
||||
foreach (var missing in missingOrdered)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Configured plugin '{PluginName}' was not found in the plugin directory.",
|
||||
missing);
|
||||
}
|
||||
}
|
||||
|
||||
return new AuthorityPluginRegistrationSummary(registered, failures, missingOrdered);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Type> DiscoverRegistrars(IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies)
|
||||
{
|
||||
var registrars = new List<Type>();
|
||||
|
||||
foreach (var descriptor in loadedAssemblies)
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(descriptor.Assembly))
|
||||
{
|
||||
if (!typeof(IAuthorityPluginRegistrar).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
registrars.Add(type);
|
||||
}
|
||||
}
|
||||
|
||||
return registrars;
|
||||
}
|
||||
|
||||
private static RegistrarActivation? TryResolveActivationForManifest(
|
||||
IServiceCollection services,
|
||||
string pluginType,
|
||||
IReadOnlyList<Type> registrarCandidates,
|
||||
IDictionary<string, Type> pluginTypeLookup,
|
||||
IDictionary<Type, string> registrarTypeLookup,
|
||||
ILogger? logger,
|
||||
out Type? resolvedType)
|
||||
{
|
||||
resolvedType = null;
|
||||
|
||||
if (pluginTypeLookup.TryGetValue(pluginType, out var cachedType))
|
||||
{
|
||||
var cachedActivation = CreateRegistrarActivation(services, cachedType, logger);
|
||||
if (cachedActivation is null)
|
||||
{
|
||||
pluginTypeLookup.Remove(pluginType);
|
||||
registrarTypeLookup.Remove(cachedType);
|
||||
return null;
|
||||
}
|
||||
|
||||
resolvedType = cachedType;
|
||||
return cachedActivation;
|
||||
}
|
||||
|
||||
foreach (var candidate in registrarCandidates)
|
||||
{
|
||||
if (registrarTypeLookup.TryGetValue(candidate, out var knownType))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(knownType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(knownType, pluginType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = CreateRegistrarActivation(services, candidate, logger);
|
||||
if (activation is null)
|
||||
{
|
||||
registrarTypeLookup.Remove(candidate);
|
||||
pluginTypeLookup.Remove(knownType);
|
||||
return null;
|
||||
}
|
||||
|
||||
resolvedType = candidate;
|
||||
return activation;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var attempt = CreateRegistrarActivation(services, candidate, logger);
|
||||
if (attempt is null)
|
||||
{
|
||||
registrarTypeLookup[candidate] = string.Empty;
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateType = attempt.Registrar.PluginType;
|
||||
if (string.IsNullOrWhiteSpace(candidateType))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Authority plugin registrar '{RegistrarType}' reported an empty plugin type and will be ignored.",
|
||||
candidate.FullName);
|
||||
registrarTypeLookup[candidate] = string.Empty;
|
||||
attempt.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
registrarTypeLookup[candidate] = candidateType;
|
||||
pluginTypeLookup[candidateType] = candidate;
|
||||
|
||||
if (string.Equals(candidateType, pluginType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
resolvedType = candidate;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
attempt.Dispose();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RegistrarActivation? CreateRegistrarActivation(IServiceCollection services, Type registrarType, ILogger? logger)
|
||||
{
|
||||
ServiceProvider? provider = null;
|
||||
IServiceScope? scope = null;
|
||||
try
|
||||
{
|
||||
provider = services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateScopes = true
|
||||
});
|
||||
|
||||
scope = provider.CreateScope();
|
||||
var registrar = (IAuthorityPluginRegistrar)ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, registrarType);
|
||||
return new RegistrarActivation(provider, scope, registrar);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to activate Authority plugin registrar '{RegistrarType}'.",
|
||||
registrarType.FullName);
|
||||
|
||||
scope?.Dispose();
|
||||
provider?.Dispose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegistrarActivation : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider provider;
|
||||
private readonly IServiceScope scope;
|
||||
|
||||
public RegistrarActivation(ServiceProvider provider, IServiceScope scope, IAuthorityPluginRegistrar registrar)
|
||||
{
|
||||
this.provider = provider;
|
||||
this.scope = scope;
|
||||
Registrar = registrar;
|
||||
}
|
||||
|
||||
public IAuthorityPluginRegistrar Registrar { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
scope.Dispose();
|
||||
provider.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAssemblyLoaded(
|
||||
AuthorityPluginManifest manifest,
|
||||
IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(manifest.AssemblyName) &&
|
||||
loadedAssemblies.Any(descriptor =>
|
||||
string.Equals(
|
||||
descriptor.Assembly.GetName().Name,
|
||||
manifest.AssemblyName,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifest.AssemblyPath) &&
|
||||
loadedAssemblies.Any(descriptor =>
|
||||
string.Equals(
|
||||
descriptor.AssemblyPath,
|
||||
manifest.AssemblyPath,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// As a fallback, assume any loaded assembly whose simple name contains the plugin type is a match.
|
||||
return loadedAssemblies.Any(descriptor =>
|
||||
descriptor.Assembly.GetName().Name?.Contains(manifest.Type, StringComparison.OrdinalIgnoreCase) == true);
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(static type => type is not null)!;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct LoadedPluginDescriptor(
|
||||
Assembly Assembly,
|
||||
string AssemblyPath);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Authority.Plugins;
|
||||
|
||||
internal static class AuthorityPluginLoader
|
||||
{
|
||||
public static AuthorityPluginRegistrationSummary RegisterPlugins(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
PluginHostOptions hostOptions,
|
||||
IReadOnlyCollection<AuthorityPluginContext> pluginContexts,
|
||||
ILogger? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(hostOptions);
|
||||
ArgumentNullException.ThrowIfNull(pluginContexts);
|
||||
|
||||
if (pluginContexts.Count == 0)
|
||||
{
|
||||
return AuthorityPluginRegistrationSummary.Empty;
|
||||
}
|
||||
|
||||
var loadResult = PluginHost.LoadPlugins(hostOptions, logger);
|
||||
var descriptors = loadResult.Plugins
|
||||
.Select(p => new LoadedPluginDescriptor(p.Assembly, p.AssemblyPath))
|
||||
.ToArray();
|
||||
|
||||
return RegisterPluginsCore(
|
||||
services,
|
||||
configuration,
|
||||
pluginContexts,
|
||||
descriptors,
|
||||
loadResult.MissingOrderedPlugins,
|
||||
logger);
|
||||
}
|
||||
|
||||
internal static AuthorityPluginRegistrationSummary RegisterPluginsCore(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IReadOnlyCollection<AuthorityPluginContext> pluginContexts,
|
||||
IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies,
|
||||
IReadOnlyCollection<string> missingOrdered,
|
||||
ILogger? logger)
|
||||
{
|
||||
var registrarCandidates = DiscoverRegistrars(loadedAssemblies);
|
||||
var pluginTypeLookup = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
|
||||
var registrarTypeLookup = new Dictionary<Type, string>();
|
||||
var registered = new List<string>();
|
||||
var failures = new List<AuthorityPluginRegistrationFailure>();
|
||||
|
||||
foreach (var pluginContext in pluginContexts)
|
||||
{
|
||||
var manifest = pluginContext.Manifest;
|
||||
|
||||
if (!manifest.Enabled)
|
||||
{
|
||||
logger?.LogInformation(
|
||||
"Skipping disabled Authority plugin '{PluginName}' ({PluginType}).",
|
||||
manifest.Name,
|
||||
manifest.Type);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsAssemblyLoaded(manifest, loadedAssemblies))
|
||||
{
|
||||
var reason = $"Assembly '{manifest.AssemblyName ?? manifest.AssemblyPath ?? manifest.Type}' was not loaded.";
|
||||
logger?.LogError(
|
||||
"Failed to register Authority plugin '{PluginName}': {Reason}",
|
||||
manifest.Name,
|
||||
reason);
|
||||
failures.Add(new AuthorityPluginRegistrationFailure(manifest.Name, reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
var activation = TryResolveActivationForManifest(
|
||||
services,
|
||||
manifest.Type,
|
||||
registrarCandidates,
|
||||
pluginTypeLookup,
|
||||
registrarTypeLookup,
|
||||
logger,
|
||||
out var registrarType);
|
||||
|
||||
if (activation is null || registrarType is null)
|
||||
{
|
||||
var reason = $"No registrar found for plugin type '{manifest.Type}'.";
|
||||
logger?.LogError(
|
||||
"Failed to register Authority plugin '{PluginName}': {Reason}",
|
||||
manifest.Name,
|
||||
reason);
|
||||
failures.Add(new AuthorityPluginRegistrationFailure(manifest.Name, reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PluginServiceRegistration.RegisterAssemblyMetadata(services, registrarType.Assembly, logger);
|
||||
|
||||
activation.Registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
registered.Add(manifest.Name);
|
||||
|
||||
logger?.LogInformation(
|
||||
"Registered Authority plugin '{PluginName}' ({PluginType}).",
|
||||
manifest.Name,
|
||||
manifest.Type);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var reason = $"Registration threw {ex.GetType().Name}.";
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to register Authority plugin '{PluginName}'.",
|
||||
manifest.Name);
|
||||
failures.Add(new AuthorityPluginRegistrationFailure(manifest.Name, reason));
|
||||
}
|
||||
finally
|
||||
{
|
||||
activation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (missingOrdered.Count > 0)
|
||||
{
|
||||
foreach (var missing in missingOrdered)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Configured plugin '{PluginName}' was not found in the plugin directory.",
|
||||
missing);
|
||||
}
|
||||
}
|
||||
|
||||
return new AuthorityPluginRegistrationSummary(registered, failures, missingOrdered);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Type> DiscoverRegistrars(IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies)
|
||||
{
|
||||
var registrars = new List<Type>();
|
||||
|
||||
foreach (var descriptor in loadedAssemblies)
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(descriptor.Assembly))
|
||||
{
|
||||
if (!typeof(IAuthorityPluginRegistrar).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
registrars.Add(type);
|
||||
}
|
||||
}
|
||||
|
||||
return registrars;
|
||||
}
|
||||
|
||||
private static RegistrarActivation? TryResolveActivationForManifest(
|
||||
IServiceCollection services,
|
||||
string pluginType,
|
||||
IReadOnlyList<Type> registrarCandidates,
|
||||
IDictionary<string, Type> pluginTypeLookup,
|
||||
IDictionary<Type, string> registrarTypeLookup,
|
||||
ILogger? logger,
|
||||
out Type? resolvedType)
|
||||
{
|
||||
resolvedType = null;
|
||||
|
||||
if (pluginTypeLookup.TryGetValue(pluginType, out var cachedType))
|
||||
{
|
||||
var cachedActivation = CreateRegistrarActivation(services, cachedType, logger);
|
||||
if (cachedActivation is null)
|
||||
{
|
||||
pluginTypeLookup.Remove(pluginType);
|
||||
registrarTypeLookup.Remove(cachedType);
|
||||
return null;
|
||||
}
|
||||
|
||||
resolvedType = cachedType;
|
||||
return cachedActivation;
|
||||
}
|
||||
|
||||
foreach (var candidate in registrarCandidates)
|
||||
{
|
||||
if (registrarTypeLookup.TryGetValue(candidate, out var knownType))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(knownType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(knownType, pluginType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var activation = CreateRegistrarActivation(services, candidate, logger);
|
||||
if (activation is null)
|
||||
{
|
||||
registrarTypeLookup.Remove(candidate);
|
||||
pluginTypeLookup.Remove(knownType);
|
||||
return null;
|
||||
}
|
||||
|
||||
resolvedType = candidate;
|
||||
return activation;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var attempt = CreateRegistrarActivation(services, candidate, logger);
|
||||
if (attempt is null)
|
||||
{
|
||||
registrarTypeLookup[candidate] = string.Empty;
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateType = attempt.Registrar.PluginType;
|
||||
if (string.IsNullOrWhiteSpace(candidateType))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Authority plugin registrar '{RegistrarType}' reported an empty plugin type and will be ignored.",
|
||||
candidate.FullName);
|
||||
registrarTypeLookup[candidate] = string.Empty;
|
||||
attempt.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
registrarTypeLookup[candidate] = candidateType;
|
||||
pluginTypeLookup[candidateType] = candidate;
|
||||
|
||||
if (string.Equals(candidateType, pluginType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
resolvedType = candidate;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
attempt.Dispose();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RegistrarActivation? CreateRegistrarActivation(IServiceCollection services, Type registrarType, ILogger? logger)
|
||||
{
|
||||
ServiceProvider? provider = null;
|
||||
IServiceScope? scope = null;
|
||||
try
|
||||
{
|
||||
provider = services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateScopes = true
|
||||
});
|
||||
|
||||
scope = provider.CreateScope();
|
||||
var registrar = (IAuthorityPluginRegistrar)ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, registrarType);
|
||||
return new RegistrarActivation(provider, scope, registrar);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to activate Authority plugin registrar '{RegistrarType}'.",
|
||||
registrarType.FullName);
|
||||
|
||||
scope?.Dispose();
|
||||
provider?.Dispose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegistrarActivation : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider provider;
|
||||
private readonly IServiceScope scope;
|
||||
|
||||
public RegistrarActivation(ServiceProvider provider, IServiceScope scope, IAuthorityPluginRegistrar registrar)
|
||||
{
|
||||
this.provider = provider;
|
||||
this.scope = scope;
|
||||
Registrar = registrar;
|
||||
}
|
||||
|
||||
public IAuthorityPluginRegistrar Registrar { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
scope.Dispose();
|
||||
provider.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAssemblyLoaded(
|
||||
AuthorityPluginManifest manifest,
|
||||
IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(manifest.AssemblyName) &&
|
||||
loadedAssemblies.Any(descriptor =>
|
||||
string.Equals(
|
||||
descriptor.Assembly.GetName().Name,
|
||||
manifest.AssemblyName,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifest.AssemblyPath) &&
|
||||
loadedAssemblies.Any(descriptor =>
|
||||
string.Equals(
|
||||
descriptor.AssemblyPath,
|
||||
manifest.AssemblyPath,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// As a fallback, assume any loaded assembly whose simple name contains the plugin type is a match.
|
||||
return loadedAssemblies.Any(descriptor =>
|
||||
descriptor.Assembly.GetName().Name?.Contains(manifest.Type, StringComparison.OrdinalIgnoreCase) == true);
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(static type => type is not null)!;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct LoadedPluginDescriptor(
|
||||
Assembly Assembly,
|
||||
string AssemblyPath);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugins;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Console;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Sessions;
|
||||
using StellaOps.Authority.Storage.Postgres;
|
||||
using StellaOps.Authority.Storage.PostgresAdapters;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
@@ -54,7 +54,7 @@ using System.Text;
|
||||
using StellaOps.Authority.Signing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Authority.OpenApi;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
@@ -249,7 +249,7 @@ builder.Services.AddAuthorityPostgresStorage(options =>
|
||||
options.AutoMigrate = true;
|
||||
options.MigrationsPath = "Migrations";
|
||||
});
|
||||
builder.Services.TryAddSingleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>();
|
||||
builder.Services.TryAddSingleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>();
|
||||
builder.Services.TryAddScoped<IAuthorityBootstrapInviteStore, PostgresBootstrapInviteStore>();
|
||||
builder.Services.TryAddScoped<IAuthorityServiceAccountStore, PostgresServiceAccountStore>();
|
||||
builder.Services.TryAddScoped<IAuthorityClientStore, PostgresClientStore>();
|
||||
@@ -1325,7 +1325,7 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
string accountId,
|
||||
IAuthorityServiceAccountStore accountStore,
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
IAuthoritySessionAccessor sessionAccessor,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
@@ -1355,7 +1355,7 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
HttpContext httpContext,
|
||||
IAuthorityServiceAccountStore accountStore,
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
IAuthoritySessionAccessor sessionAccessor,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.RateLimiting;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata captured for the current request to assist rate limiter partitioning and diagnostics.
|
||||
/// </summary>
|
||||
internal sealed class AuthorityRateLimiterMetadata
|
||||
{
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private readonly Dictionary<string, string> tags = new(OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Canonical endpoint associated with the request (e.g. "/token").
|
||||
/// </summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote IP address observed for the request (post proxy resolution where available).
|
||||
/// </summary>
|
||||
public string? RemoteIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded IP address extracted from proxy headers (if present).
|
||||
/// </summary>
|
||||
public string? ForwardedFor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth client identifier associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject identifier (user) associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Project identifier associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? Project { get; set; } = StellaOpsTenancyDefaults.AnyProject;
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata tags that can be attached by later handlers.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Tags => tags;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string associated with the request, if captured.
|
||||
/// </summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates an arbitrary metadata tag for downstream consumers.
|
||||
/// </summary>
|
||||
/// <param name="name">The tag name.</param>
|
||||
/// <param name="value">The tag value (removed when null/whitespace).</param>
|
||||
public void SetTag(string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
tags.Remove(name);
|
||||
return;
|
||||
}
|
||||
|
||||
tags[name] = value;
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.RateLimiting;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata captured for the current request to assist rate limiter partitioning and diagnostics.
|
||||
/// </summary>
|
||||
internal sealed class AuthorityRateLimiterMetadata
|
||||
{
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private readonly Dictionary<string, string> tags = new(OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Canonical endpoint associated with the request (e.g. "/token").
|
||||
/// </summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote IP address observed for the request (post proxy resolution where available).
|
||||
/// </summary>
|
||||
public string? RemoteIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded IP address extracted from proxy headers (if present).
|
||||
/// </summary>
|
||||
public string? ForwardedFor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth client identifier associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject identifier (user) associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Project identifier associated with the request, when available.
|
||||
/// </summary>
|
||||
public string? Project { get; set; } = StellaOpsTenancyDefaults.AnyProject;
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata tags that can be attached by later handlers.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Tags => tags;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string associated with the request, if captured.
|
||||
/// </summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates an arbitrary metadata tag for downstream consumers.
|
||||
/// </summary>
|
||||
/// <param name="name">The tag name.</param>
|
||||
/// <param name="value">The tag value (removed when null/whitespace).</param>
|
||||
public void SetTag(string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
tags.Remove(name);
|
||||
return;
|
||||
}
|
||||
|
||||
tags[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,129 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.RateLimiting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the rate limiter metadata for the current HTTP request.
|
||||
/// </summary>
|
||||
internal interface IAuthorityRateLimiterMetadataAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the metadata for the current request, if available.
|
||||
/// </summary>
|
||||
/// <returns>The metadata instance or null when no HTTP context is present.</returns>
|
||||
AuthorityRateLimiterMetadata? GetMetadata();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the client identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetClientId(string? clientId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the subject identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetSubjectId(string? subjectId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the tenant identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetTenant(string? tenant);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the project identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetProject(string? project);
|
||||
|
||||
/// <summary>
|
||||
/// Adds or removes a metadata tag for the current request.
|
||||
/// </summary>
|
||||
void SetTag(string name, string? value);
|
||||
}
|
||||
|
||||
internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
|
||||
public AuthorityRateLimiterMetadataAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
}
|
||||
|
||||
public AuthorityRateLimiterMetadata? GetMetadata()
|
||||
{
|
||||
return TryGetMetadata();
|
||||
}
|
||||
|
||||
public void SetClientId(string? clientId)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.ClientId = Normalize(clientId);
|
||||
metadata.SetTag("authority.client_id", metadata.ClientId);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSubjectId(string? subjectId)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.SubjectId = Normalize(subjectId);
|
||||
metadata.SetTag("authority.subject_id", metadata.SubjectId);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTenant(string? tenant)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.Tenant = NormalizeTenant(tenant);
|
||||
metadata.SetTag("authority.tenant", metadata.Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetProject(string? project)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.Project = NormalizeProject(project);
|
||||
metadata.SetTag("authority.project", metadata.Project);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTag(string name, string? value)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
metadata?.SetTag(name, value);
|
||||
}
|
||||
|
||||
private AuthorityRateLimiterMetadata? TryGetMetadata()
|
||||
{
|
||||
var context = httpContextAccessor.HttpContext;
|
||||
return context?.Features.Get<IAuthorityRateLimiterFeature>()?.Metadata;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizeProject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return StellaOpsTenancyDefaults.AnyProject;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.RateLimiting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the rate limiter metadata for the current HTTP request.
|
||||
/// </summary>
|
||||
internal interface IAuthorityRateLimiterMetadataAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the metadata for the current request, if available.
|
||||
/// </summary>
|
||||
/// <returns>The metadata instance or null when no HTTP context is present.</returns>
|
||||
AuthorityRateLimiterMetadata? GetMetadata();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the client identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetClientId(string? clientId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the subject identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetSubjectId(string? subjectId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the tenant identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetTenant(string? tenant);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the project identifier associated with the current request.
|
||||
/// </summary>
|
||||
void SetProject(string? project);
|
||||
|
||||
/// <summary>
|
||||
/// Adds or removes a metadata tag for the current request.
|
||||
/// </summary>
|
||||
void SetTag(string name, string? value);
|
||||
}
|
||||
|
||||
internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor httpContextAccessor;
|
||||
|
||||
public AuthorityRateLimiterMetadataAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
}
|
||||
|
||||
public AuthorityRateLimiterMetadata? GetMetadata()
|
||||
{
|
||||
return TryGetMetadata();
|
||||
}
|
||||
|
||||
public void SetClientId(string? clientId)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.ClientId = Normalize(clientId);
|
||||
metadata.SetTag("authority.client_id", metadata.ClientId);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSubjectId(string? subjectId)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.SubjectId = Normalize(subjectId);
|
||||
metadata.SetTag("authority.subject_id", metadata.SubjectId);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTenant(string? tenant)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.Tenant = NormalizeTenant(tenant);
|
||||
metadata.SetTag("authority.tenant", metadata.Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetProject(string? project)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.Project = NormalizeProject(project);
|
||||
metadata.SetTag("authority.project", metadata.Project);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTag(string name, string? value)
|
||||
{
|
||||
var metadata = TryGetMetadata();
|
||||
metadata?.SetTag(name, value);
|
||||
}
|
||||
|
||||
private AuthorityRateLimiterMetadata? TryGetMetadata()
|
||||
{
|
||||
var context = httpContextAccessor.HttpContext;
|
||||
return context?.Features.Get<IAuthorityRateLimiterFeature>()?.Metadata;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizeProject(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return StellaOpsTenancyDefaults.AnyProject;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user