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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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)]

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -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 = "*";
}

View File

@@ -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);
}
}

View File

@@ -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()
{
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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()
{
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>());
}
}

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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>>()));
}
}

View File

@@ -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" />

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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.

View File

@@ -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;

View File

@@ -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.

View File

@@ -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.

View File

@@ -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;

View File

@@ -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 =>
{

View File

@@ -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;

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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 =>
{

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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));
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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");

View File

@@ -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; }
}
}

View File

@@ -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
});
}

View File

@@ -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"]);
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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";
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -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>();
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -1,3 +1,3 @@
public partial class Program
{
}
public partial class Program
{
}

View File

@@ -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) =>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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