Initial commit (history squashed)
Some checks failed
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
2025-10-07 10:14:21 +03:00
commit b97fc7685a
1132 changed files with 117842 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
````markdown
# Feedser Vulnerability Conflict Resolution Rules
This document defines the canonical, deterministic conflict resolution strategy for merging vulnerability data from **NVD**, **GHSA**, and **OSV** in Feedser.
---
## 🧭 Source Precedence
1. **Primary order:**
`GHSA > NVD > OSV`
**Rationale:**
GHSA advisories are human-curated and fast to correct; NVD has the broadest CVE coverage; OSV excels in ecosystem-specific precision.
2. **Freshness override (≥48 h):**
If a **lower-priority** source is **newer by at least 48 hours** for a freshness-sensitive field, its value overrides the higher-priority one.
Always store the decision in a provenance record.
3. **Merge scope:**
Only merge data referring to the **same CVE ID** or the same GHSA/OSV advisory explicitly mapped to that CVE.
---
## 🧩 Field-Level Precedence
| Field | Priority | Freshness-Sensitive | Notes |
|-------|-----------|--------------------|-------|
| Title / Summary | GHSA → NVD → OSV | ✅ | Prefer concise structured titles |
| Description | GHSA → NVD → OSV | ✅ | |
| Severity (CVSS) | NVD → GHSA → OSV | ❌ | Keep all under `metrics[]`, mark `canonicalMetric` by order |
| Ecosystem Severity Label | GHSA → OSV | ❌ | Supplemental tag only |
| Affected Packages / Ranges | OSV → GHSA → NVD | ✅ | OSV strongest for SemVer normalization |
| CWE(s) | NVD → GHSA → OSV | ❌ | NVD taxonomy most stable |
| References / Links | Union of all | ✅ | Deduplicate by normalized URL |
| Credits / Acknowledgements | Union of all | ✅ | Sort by role, displayName |
| Published / Modified timestamps | Earliest published / Latest modified | ✅ | |
| EPSS / KEV / Exploit status | Specialized feed only | ❌ | Do not override manually |
---
## ⚖️ Deterministic Tie-Breakers
If precedence and freshness both tie:
1. **Source order:** GHSA > NVD > OSV
2. **Lexicographic stability:** Prefer shorter normalized text; if equal, ASCIIbetical
3. **Stable hash of payload:** Lowest hash wins
Each chosen value must store the merge rationale:
```json
{
"provenance": {
"source": "GHSA",
"kind": "merge",
"value": "description",
"decisionReason": "precedence"
}
}
````
---
## 🧮 Merge Algorithm (Pseudocode)
```csharp
inputs: records = {ghsa?, nvd?, osv?}
out = new CanonicalVuln(CVE)
foreach field in CANONICAL_SCHEMA:
candidates = collect(values, source, lastModified)
if freshnessSensitive(field) and newerBy48h(lowerPriority):
pick newest
else:
pick by precedence(field)
if tie:
applyTieBreakers()
out.field = normalize(field, value)
out.provenance[field] = decisionTrail
out.references = dedupe(union(all.references))
out.affected = normalizeAndUnion(OSV, GHSA, NVD)
out.metrics = rankAndSetCanonical(NVDv3 → GHSA → OSV → v2)
return out
```
---
## 🔧 Normalization Rules
* **SemVer:**
Parse with tolerant builder; normalize `v` prefixes; map comparators (`<=`, `<`, `>=`, `>`); expand OSV events into continuous ranges.
* **Packages:**
Canonical key = `(ecosystem, packageName, language?)`; maintain aliases (purl, npm, Maven GAV, etc.).
* **CWE:**
Store both ID and name; validate against current CWE catalog.
* **CVSS:**
Preserve provided vector and base score; recompute only for validation.
---
## ✅ Output Guarantees
| Property | Description |
| ---------------- | ------------------------------------------------------------------------------- |
| **Reproducible** | Same input → same canonical output |
| **Auditable** | Provenance stored per field |
| **Complete** | Unions with de-duplication |
| **Composable** | Future layers (KEV, EPSS, vendor advisories) can safely extend precedence rules |
---
## 🧠 Example
* GHSA summary updated on *2025-10-09*
* NVD last modified *2025-10-05*
* OSV updated *2025-10-10*
→ **Summary:** OSV wins (freshness override)
→ **CVSS:** NVD v3.1 remains canonical
→ **Affected:** OSV ranges canonical; GHSA aliases merged
---
## 🧰 Optional C# Helper Class
`StellaOps.Feedser.Core/CanonicalMerger.cs`
Implements:
* `FieldPrecedenceMap`
* `FreshnessSensitiveFields`
* `ApplyTieBreakers()`
* `NormalizeAndUnion()`
Deterministically builds `CanonicalVuln` with full provenance tracking.
```
```

33
src/Directory.Build.props Normal file
View File

@@ -0,0 +1,33 @@
<Project>
<PropertyGroup>
<FeedserPluginOutputRoot Condition="'$(FeedserPluginOutputRoot)' == ''">$(SolutionDir)PluginBinaries</FeedserPluginOutputRoot>
<FeedserPluginOutputRoot Condition="'$(FeedserPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)PluginBinaries</FeedserPluginOutputRoot>
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == ''">$(SolutionDir)PluginBinaries\Authority</AuthorityPluginOutputRoot>
<AuthorityPluginOutputRoot Condition="'$(AuthorityPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)PluginBinaries\Authority</AuthorityPluginOutputRoot>
<IsFeedserPlugin Condition="'$(IsFeedserPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Feedser.Source.'))">true</IsFeedserPlugin>
<IsFeedserPlugin Condition="'$(IsFeedserPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Feedser.Exporter.'))">true</IsFeedserPlugin>
<IsAuthorityPlugin Condition="'$(IsAuthorityPlugin)' == '' and $([System.String]::Copy('$(MSBuildProjectName)').StartsWith('StellaOps.Authority.Plugin.'))">true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<ProjectReference Update="../StellaOps.Plugin/StellaOps.Plugin.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests'))">
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.4.0" />
<Compile Include="$(MSBuildThisFileDirectory)StellaOps.Feedser.Tests.Shared\AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" />
<Compile Include="$(MSBuildThisFileDirectory)StellaOps.Feedser.Tests.Shared\MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" />
<ProjectReference Include="$(MSBuildThisFileDirectory)StellaOps.Feedser.Testing\StellaOps.Feedser.Testing.csproj" />
<Using Include="StellaOps.Feedser.Testing" />
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
<Project>
<Target Name="FeedserCopyPluginArtifacts" AfterTargets="Build" Condition="'$(IsFeedserPlugin)' == 'true'">
<PropertyGroup>
<FeedserPluginOutputDirectory>$(FeedserPluginOutputRoot)\$(MSBuildProjectName)</FeedserPluginOutputDirectory>
</PropertyGroup>
<MakeDir Directories="$(FeedserPluginOutputDirectory)" />
<ItemGroup>
<FeedserPluginArtifacts Include="$(TargetPath)" />
<FeedserPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" />
<FeedserPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
</ItemGroup>
<Copy SourceFiles="@(FeedserPluginArtifacts)" DestinationFolder="$(FeedserPluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
<Target Name="AuthorityCopyPluginArtifacts" AfterTargets="Build" Condition="'$(IsAuthorityPlugin)' == 'true'">
<PropertyGroup>
<AuthorityPluginOutputDirectory>$(AuthorityPluginOutputRoot)\$(MSBuildProjectName)</AuthorityPluginOutputDirectory>
</PropertyGroup>
<MakeDir Directories="$(AuthorityPluginOutputDirectory)" />
<ItemGroup>
<AuthorityPluginArtifacts Include="$(TargetPath)" />
<AuthorityPluginArtifacts Include="$(TargetPath).deps.json" Condition="Exists('$(TargetPath).deps.json')" />
<AuthorityPluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
</ItemGroup>
<Copy SourceFiles="@(AuthorityPluginArtifacts)" DestinationFolder="$(AuthorityPluginOutputDirectory)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,139 @@
Heres a quick, practical idea to make your version-range modeling cleaner and faster to query.
![A simple diagram showing a Vulnerability doc with an embedded normalizedVersions array next to a pipeline icon labeled “simpler aggregations”.](https://images.unsplash.com/photo-1515879218367-8466d910aaa4?q=80\&w=1470\&auto=format\&fit=crop)
# Rethinking `SemVerRangeBuilder` + MongoDB
**Problem (today):** Version normalization rules live as a nested object (and often as a bespoke structure per source). This can force awkward `$objectToArray`, `$map`, and conditional logic in pipelines when you need to:
* match “is version X affected?”
* flatten ranges for analytics
* de-duplicate across sources
**Proposal:** Store *normalized version rules as an embedded collection (array of small docs)* instead of a single nested object.
## Minimal background
* **SemVer normalization**: converting all source-specific version notations into a single, strict representation (e.g., `>=1.2.3 <2.0.0`, exact pins, wildcards).
* **Embedded collection**: an array of consistently shaped items inside the parent doc—great for `$unwind`-centric analytics and direct matches.
## Suggested shape
```json
{
"_id": "VULN-123",
"packageId": "pkg:npm/lodash",
"source": "NVD",
"normalizedVersions": [
{
"scheme": "semver",
"type": "range", // "range" | "exact" | "lt" | "lte" | "gt" | "gte"
"min": "1.2.3", // optional
"minInclusive": true, // optional
"max": "2.0.0", // optional
"maxInclusive": false, // optional
"notes": "from GHSA GHSA-xxxx" // traceability
},
{
"scheme": "semver",
"type": "exact",
"value": "1.5.0"
}
],
"metadata": { "ingestedAt": "2025-10-10T12:00:00Z" }
}
```
### Why this helps
* **Simpler queries**
* *Is v affected?*
```js
db.vulns.aggregate([
{ $match: { packageId: "pkg:npm/lodash" } },
{ $unwind: "$normalizedVersions" },
{ $match: {
$or: [
{ "normalizedVersions.type": "exact", "normalizedVersions.value": "1.5.0" },
{ "normalizedVersions.type": "range",
"normalizedVersions.min": { $lte: "1.5.0" },
"normalizedVersions.max": { $gt: "1.5.0" } }
]
}},
{ $project: { _id: 1 } }
])
```
* No `$objectToArray`, fewer `$cond`s.
* **Cheaper storage**
* Arrays of tiny docs compress well and avoid wide nested structures with many nulls/keys.
* **Easier dedup/merge**
* `$unwind` → normalize → `$group` by `{scheme,type,min,max,value}` to collapse equivalent rules across sources.
## Builder changes (`SemVerRangeBuilder`)
* **Emit items, not a monolith**: have the builder return `IEnumerable<NormalizedVersionRule>`.
* **Normalize early**: resolve “aliases” (`1.2.x`, `^1.2.3`, distro styles) into canonical `(type,min,max,…)` before persistence.
* **Traceability**: include `notes`/`sourceRef` on each rule so you can re-materialize provenance during audits.
### C# sketch
```csharp
public record NormalizedVersionRule(
string Scheme, // "semver"
string Type, // "range" | "exact" | ...
string? Min = null,
bool? MinInclusive = null,
string? Max = null,
bool? MaxInclusive = null,
string? Value = null,
string? Notes = null
);
public static class SemVerRangeBuilder
{
public static IEnumerable<NormalizedVersionRule> Build(string raw)
{
// parse raw (^1.2.3, 1.2.x, <=2.0.0, etc.)
// yield canonical rules:
yield return new NormalizedVersionRule(
Scheme: "semver",
Type: "range",
Min: "1.2.3",
MinInclusive: true,
Max: "2.0.0",
MaxInclusive: false,
Notes: "nvd:ABC-123"
);
}
}
```
## Aggregation patterns you unlock
* **Fast “affected version” lookups** via `$unwind + $match` (can complement with a computed sort key).
* **Rollups**: count of vulns per `(major,minor)` by mapping each rule into bucketed segments.
* **Cross-source reconciliation**: group identical rules to de-duplicate.
## Indexing tips
* Compound index on `{ packageId: 1, "normalizedVersions.scheme": 1, "normalizedVersions.type": 1 }`.
* If lookups by exact value are common: add a sparse index on `"normalizedVersions.value"`.
## Migration path (safe + incremental)
1. **Dual-write**: keep old nested object while writing the new `normalizedVersions` array.
2. **Backfill** existing docs with a one-time script using your current builder.
3. **Cutover** queries/aggregations to the new path (behind a feature flag).
4. **Clean up** old field after soak.
If you want, I can draft:
* a one-time Mongo backfill script,
* the new EF/Mongo C# POCOs, and
* a test matrix (edge cases: prerelease tags, build metadata, `0.*` semantics, distro-style ranges).

View File

@@ -0,0 +1,20 @@
# Authority Host Crew
## Mission
Own the StellaOps Authority host service: ASP.NET minimal API, OpenIddict flows, plugin loading, storage orchestration, and cross-cutting security controls (rate limiting, audit logging, revocation exports).
## Teams On Call
- Team 2 (Authority Core)
- Team 8 (Security Guild) — collaborates on security-sensitive endpoints
## Operating Principles
- Deterministic responses, structured logging, cancellation-ready handlers.
- Use `StellaOps.Cryptography` abstractions for any crypto operations.
- Every change updates `TASKS.md` and related docs/tests.
- Coordinate with plugin teams before altering plugin-facing contracts.
## Key Directories
- `src/StellaOps.Authority/` — host app
- `src/StellaOps.Authority.Tests/` — integration/unit tests
- `src/StellaOps.Authority.Storage.Mongo/` — data access helpers
- `src/StellaOps.Authority.Plugin.Standard/` — default identity provider plugin

View File

@@ -0,0 +1,75 @@
using System;
using System.Net;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class NetworkMaskMatcherTests
{
[Fact]
public void Parse_SingleAddress_YieldsHostMask()
{
var mask = NetworkMask.Parse("192.168.1.42");
Assert.Equal(32, mask.PrefixLength);
Assert.True(mask.Contains(IPAddress.Parse("192.168.1.42")));
Assert.False(mask.Contains(IPAddress.Parse("192.168.1.43")));
}
[Fact]
public void Parse_Cidr_NormalisesHostBits()
{
var mask = NetworkMask.Parse("10.0.15.9/20");
Assert.Equal("10.0.0.0/20", mask.ToString());
Assert.True(mask.Contains(IPAddress.Parse("10.0.8.1")));
Assert.False(mask.Contains(IPAddress.Parse("10.0.32.1")));
}
[Fact]
public void Contains_ReturnsFalse_ForMismatchedAddressFamily()
{
var mask = NetworkMask.Parse("192.168.0.0/16");
Assert.False(mask.Contains(IPAddress.IPv6Loopback));
}
[Fact]
public void Matcher_AllowsAll_WhenStarProvided()
{
var matcher = new NetworkMaskMatcher(new[] { "*" });
Assert.False(matcher.IsEmpty);
Assert.True(matcher.IsAllowed(IPAddress.Parse("203.0.113.10")));
Assert.True(matcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Matcher_ReturnsFalse_WhenNoMasksConfigured()
{
var matcher = new NetworkMaskMatcher(Array.Empty<string>());
Assert.True(matcher.IsEmpty);
Assert.False(matcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.False(matcher.IsAllowed(null));
}
[Fact]
public void Matcher_SupportsIpv4AndIpv6Masks()
{
var matcher = new NetworkMaskMatcher(new[] { "192.168.0.0/24", "::1/128" });
Assert.True(matcher.IsAllowed(IPAddress.Parse("192.168.0.42")));
Assert.False(matcher.IsAllowed(IPAddress.Parse("10.0.0.1")));
Assert.True(matcher.IsAllowed(IPAddress.IPv6Loopback));
Assert.False(matcher.IsAllowed(IPAddress.IPv6Any));
}
[Fact]
public void Matcher_Throws_ForInvalidEntries()
{
var exception = Assert.Throws<FormatException>(() => new NetworkMaskMatcher(new[] { "invalid-mask" }));
Assert.Contains("invalid-mask", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,74 @@
using System;
using System.Linq;
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsPrincipalBuilderTests
{
[Fact]
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
{
var builder = new StellaOpsPrincipalBuilder()
.WithScopes(new[] { "Feedser.Jobs.Trigger", " feedser.jobs.trigger ", "AUTHORITY.USERS.MANAGE" })
.WithAudiences(new[] { " api://feedser ", "api://cli", "api://feedser" });
Assert.Equal(
new[] { "authority.users.manage", "feedser.jobs.trigger" },
builder.NormalizedScopes);
Assert.Equal(
new[] { "api://cli", "api://feedser" },
builder.Audiences);
}
[Fact]
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
{
var now = DateTimeOffset.UtcNow;
var builder = new StellaOpsPrincipalBuilder()
.WithSubject(" user-1 ")
.WithClientId(" cli-01 ")
.WithTenant(" default ")
.WithName(" Jane Doe ")
.WithIdentityProvider(" internal ")
.WithSessionId(" session-123 ")
.WithTokenId(Guid.NewGuid().ToString("N"))
.WithAuthenticationMethod("password")
.WithAuthenticationType(" custom ")
.WithScopes(new[] { "Feedser.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
.WithAudience(" api://feedser ")
.WithIssuedAt(now)
.WithExpires(now.AddMinutes(5))
.AddClaim(" custom ", " value ");
var principal = builder.Build();
var identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
Assert.Equal("custom", identity.AuthenticationType);
Assert.Equal("Jane Doe", identity.Name);
Assert.Equal("user-1", principal.FindFirstValue(StellaOpsClaimTypes.Subject));
Assert.Equal("cli-01", principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
Assert.Equal("value", principal.FindFirstValue("custom"));
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, scopeClaims);
var scopeList = principal.FindFirstValue(StellaOpsClaimTypes.Scope);
Assert.Equal("authority.users.manage feedser.jobs.trigger", scopeList);
var audienceClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.Audience).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "api://feedser" }, audienceClaims);
var issuedAt = principal.FindFirstValue("iat");
Assert.Equal(now.ToUnixTimeSeconds().ToString(), issuedAt);
var expires = principal.FindFirstValue("exp");
Assert.Equal(now.AddMinutes(5).ToUnixTimeSeconds().ToString(), expires);
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsProblemResultFactoryTests
{
[Fact]
public void AuthenticationRequired_ReturnsCanonicalProblem()
{
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/authentication-required", details.Type);
Assert.Equal("Authentication required", details.Title);
Assert.Equal("/jobs", details.Instance);
Assert.Equal("unauthorized", details.Extensions["error"]);
Assert.Equal(details.Detail, details.Extensions["error_description"]);
}
[Fact]
public void InvalidToken_UsesProvidedDetail()
{
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
Assert.Equal("expired refresh token", details.Detail);
Assert.Equal("invalid_token", details.Extensions["error"]);
}
[Fact]
public void InsufficientScope_AddsScopeExtensions()
{
var result = StellaOpsProblemResultFactory.InsufficientScope(
new[] { StellaOpsScopes.FeedserJobsTrigger },
new[] { StellaOpsScopes.AuthorityUsersManage },
instance: "/jobs/trigger");
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
Assert.Equal("insufficient_scope", details.Extensions["error"]);
Assert.Equal(new[] { StellaOpsScopes.FeedserJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
Assert.Equal("/jobs/trigger", details.Instance);
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Reflection;
namespace StellaOps.Auth;
/// <summary>
/// Canonical telemetry metadata for the StellaOps Authority stack.
/// </summary>
public static class AuthorityTelemetry
{
/// <summary>
/// service.name resource attribute recorded by Authority components.
/// </summary>
public const string ServiceName = "stellaops-authority";
/// <summary>
/// service.namespace resource attribute aligning Authority with other StellaOps services.
/// </summary>
public const string ServiceNamespace = "stellaops";
/// <summary>
/// Activity source identifier used by Authority instrumentation.
/// </summary>
public const string ActivitySourceName = "StellaOps.Authority";
/// <summary>
/// Meter name used by Authority instrumentation.
/// </summary>
public const string MeterName = "StellaOps.Authority";
/// <summary>
/// Builds the default set of resource attributes (service name/namespace/version).
/// </summary>
/// <param name="assembly">Optional assembly used to resolve the service version.</param>
public static IReadOnlyDictionary<string, object> BuildDefaultResourceAttributes(Assembly? assembly = null)
{
var version = ResolveServiceVersion(assembly);
return new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase)
{
["service.name"] = ServiceName,
["service.namespace"] = ServiceNamespace,
["service.version"] = version
};
}
/// <summary>
/// Resolves the service version string from the provided assembly (defaults to the Authority telemetry assembly).
/// </summary>
public static string ResolveServiceVersion(Assembly? assembly = null)
{
assembly ??= typeof(AuthorityTelemetry).Assembly;
return assembly.GetName().Version?.ToString() ?? "0.0.0";
}
}

View File

@@ -0,0 +1,181 @@
using System;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Represents an IP network expressed in CIDR notation.
/// </summary>
public readonly record struct NetworkMask
{
private readonly IPAddress address;
/// <summary>
/// Initialises a new <see cref="NetworkMask"/>.
/// </summary>
/// <param name="network">Canonical network address with host bits zeroed.</param>
/// <param name="prefixLength">Prefix length (0-32 for IPv4, 0-128 for IPv6).</param>
public NetworkMask(IPAddress network, int prefixLength)
{
ArgumentNullException.ThrowIfNull(network);
var maxPrefix = GetMaxPrefix(network);
if (prefixLength is < 0 or > 128 || prefixLength > maxPrefix)
{
throw new ArgumentOutOfRangeException(nameof(prefixLength), $"Prefix length must be between 0 and {maxPrefix} for {network.AddressFamily}.");
}
address = Normalize(network, prefixLength);
PrefixLength = prefixLength;
}
/// <summary>
/// Canonical network address with host bits zeroed.
/// </summary>
public IPAddress Network => address;
/// <summary>
/// Prefix length.
/// </summary>
public int PrefixLength { get; }
/// <summary>
/// Attempts to parse the supplied value as CIDR notation or a single IP address.
/// </summary>
/// <exception cref="FormatException">Thrown when the input is not recognised.</exception>
public static NetworkMask Parse(string value)
{
if (!TryParse(value, out var mask))
{
throw new FormatException($"'{value}' is not a valid CIDR or IP address.");
}
return mask;
}
/// <summary>
/// Attempts to parse the supplied value as CIDR notation or a single IP address.
/// </summary>
public static bool TryParse(string? value, out NetworkMask mask)
{
mask = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
var slashIndex = trimmed.IndexOf('/', StringComparison.Ordinal);
if (slashIndex < 0)
{
if (!IPAddress.TryParse(trimmed, out var singleAddress))
{
return false;
}
var defaultPrefix = singleAddress.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
mask = new NetworkMask(singleAddress, defaultPrefix);
return true;
}
var addressPart = trimmed[..slashIndex];
var prefixPart = trimmed[(slashIndex + 1)..];
if (!IPAddress.TryParse(addressPart, out var networkAddress))
{
return false;
}
if (!int.TryParse(prefixPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var prefixLength))
{
return false;
}
try
{
mask = new NetworkMask(networkAddress, prefixLength);
return true;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
/// <summary>
/// Determines whether the provided address belongs to this network.
/// </summary>
public bool Contains(IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);
if (address.AddressFamily != this.address.AddressFamily)
{
return false;
}
if (PrefixLength == 0)
{
return true;
}
var targetBytes = address.GetAddressBytes();
var networkBytes = this.address.GetAddressBytes();
var fullBytes = PrefixLength / 8;
for (var i = 0; i < fullBytes; i++)
{
if (targetBytes[i] != networkBytes[i])
{
return false;
}
}
var remainder = PrefixLength % 8;
if (remainder == 0)
{
return true;
}
var mask = (byte)(0xFF << (8 - remainder));
return (targetBytes[fullBytes] & mask) == networkBytes[fullBytes];
}
private static int GetMaxPrefix(IPAddress address)
=> address.AddressFamily == AddressFamily.InterNetwork ? 32 :
address.AddressFamily == AddressFamily.InterNetworkV6 ? 128 :
throw new ArgumentOutOfRangeException(nameof(address), $"Unsupported address family {address.AddressFamily}.");
private static IPAddress Normalize(IPAddress address, int prefixLength)
{
var bytes = address.GetAddressBytes();
var fullBytes = prefixLength / 8;
var remainder = prefixLength % 8;
if (fullBytes < bytes.Length)
{
if (remainder > 0)
{
var mask = (byte)(0xFF << (8 - remainder));
bytes[fullBytes] &= mask;
fullBytes++;
}
for (var index = fullBytes; index < bytes.Length; index++)
{
bytes[index] = 0;
}
}
return new IPAddress(bytes);
}
/// <inheritdoc />
public override string ToString()
=> $"{Network}/{PrefixLength}";
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Evaluates remote addresses against configured network masks.
/// </summary>
public sealed class NetworkMaskMatcher
{
private readonly NetworkMask[] masks;
private readonly bool matchAll;
/// <summary>
/// Creates a matcher from raw CIDR strings.
/// </summary>
/// <param name="values">Sequence of CIDR entries or IP addresses.</param>
/// <exception cref="FormatException">Thrown when a value cannot be parsed.</exception>
public NetworkMaskMatcher(IEnumerable<string>? values)
: this(Parse(values))
{
}
/// <summary>
/// Creates a matcher from already parsed masks.
/// </summary>
/// <param name="masks">Sequence of network masks.</param>
public NetworkMaskMatcher(IEnumerable<NetworkMask> masks)
{
ArgumentNullException.ThrowIfNull(masks);
var unique = new HashSet<NetworkMask>();
foreach (var mask in masks)
{
unique.Add(mask);
}
this.masks = unique.ToArray();
matchAll = this.masks.Length == 1 && this.masks[0].PrefixLength == 0;
}
private NetworkMaskMatcher((bool MatchAll, NetworkMask[] Masks) parsed)
{
matchAll = parsed.MatchAll;
masks = parsed.Masks;
}
/// <summary>
/// Gets a matcher that allows every address.
/// </summary>
public static NetworkMaskMatcher AllowAll { get; } = new((true, Array.Empty<NetworkMask>()));
/// <summary>
/// Gets a matcher that denies every address (no masks configured).
/// </summary>
public static NetworkMaskMatcher DenyAll { get; } = new((false, Array.Empty<NetworkMask>()));
/// <summary>
/// Indicates whether this matcher has no masks configured and does not allow all.
/// </summary>
public bool IsEmpty => !matchAll && masks.Length == 0;
/// <summary>
/// Returns the configured masks.
/// </summary>
public IReadOnlyList<NetworkMask> Masks => masks;
/// <summary>
/// Checks whether the provided address matches any of the configured masks.
/// </summary>
/// <param name="address">Remote address to test.</param>
/// <returns><c>true</c> when the address is allowed.</returns>
public bool IsAllowed(IPAddress? address)
{
if (address is null)
{
return false;
}
if (matchAll)
{
return true;
}
if (masks.Length == 0)
{
return false;
}
foreach (var mask in masks)
{
if (mask.Contains(address))
{
return true;
}
}
return false;
}
private static (bool MatchAll, NetworkMask[] Masks) Parse(IEnumerable<string>? values)
{
if (values is null)
{
return (false, Array.Empty<NetworkMask>());
}
var unique = new HashSet<NetworkMask>();
foreach (var raw in values)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var trimmed = raw.Trim();
if (IsAllowAll(trimmed))
{
return (true, Array.Empty<NetworkMask>());
}
if (!NetworkMask.TryParse(trimmed, out var mask))
{
throw new FormatException($"'{trimmed}' is not a valid network mask or IP address.");
}
unique.Add(mask);
}
return (false, unique.ToArray());
}
private static bool IsAllowAll(string value)
=> value is "*" or "0.0.0.0/0" or "::/0";
}

View File

@@ -0,0 +1,9 @@
# StellaOps.Auth.Abstractions
Shared authentication primitives for StellaOps services:
- Scope and claim constants aligned with StellaOps Authority.
- Deterministic `PrincipalBuilder` and `ProblemResultFactory` helpers.
- Utility types used by resource servers, plug-ins, and client libraries.
These abstractions are referenced by `StellaOps.Auth.ServerIntegration` and `StellaOps.Auth.Client`. Review `docs/dev/32_AUTH_CLIENT_GUIDE.md` for downstream integration patterns.

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.Abstractions</PackageId>
<Description>Core authority authentication abstractions, scopes, and helpers for StellaOps services.</Description>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageTags>stellaops;authentication;authority;oauth2</PackageTags>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<PackageReadmeFile>README.NuGet.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="README.NuGet.md" Pack="true" PackagePath="" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Default authentication constants used by StellaOps resource servers and clients.
/// </summary>
public static class StellaOpsAuthenticationDefaults
{
/// <summary>
/// Default authentication scheme for StellaOps bearer tokens.
/// </summary>
public const string AuthenticationScheme = "StellaOpsBearer";
/// <summary>
/// Logical authentication type attached to <see cref="System.Security.Claims.ClaimsIdentity"/>.
/// </summary>
public const string AuthenticationType = "StellaOps";
/// <summary>
/// Policy prefix applied to named authorization policies.
/// </summary>
public const string PolicyPrefix = "StellaOps.Policy.";
}

View File

@@ -0,0 +1,57 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical claim type identifiers used across StellaOps services.
/// </summary>
public static class StellaOpsClaimTypes
{
/// <summary>
/// Subject identifier claim (maps to <c>sub</c> in JWTs).
/// </summary>
public const string Subject = "sub";
/// <summary>
/// StellaOps tenant identifier claim (multi-tenant deployments).
/// </summary>
public const string Tenant = "stellaops:tenant";
/// <summary>
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
/// </summary>
public const string ClientId = "client_id";
/// <summary>
/// Unique token identifier claim (maps to <c>jti</c>).
/// </summary>
public const string TokenId = "jti";
/// <summary>
/// Authentication method reference claim (<c>amr</c>).
/// </summary>
public const string AuthenticationMethod = "amr";
/// <summary>
/// Space separated scope list (<c>scope</c>).
/// </summary>
public const string Scope = "scope";
/// <summary>
/// Individual scope items (<c>scp</c>).
/// </summary>
public const string ScopeItem = "scp";
/// <summary>
/// OAuth2 resource audiences (<c>aud</c>).
/// </summary>
public const string Audience = "aud";
/// <summary>
/// Identity provider hint for downstream services.
/// </summary>
public const string IdentityProvider = "stellaops:idp";
/// <summary>
/// Session identifier claim (<c>sid</c>).
/// </summary>
public const string SessionId = "sid";
}

View File

@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Fluent helper used to construct <see cref="ClaimsPrincipal"/> instances that follow StellaOps conventions.
/// </summary>
public sealed class StellaOpsPrincipalBuilder
{
private readonly Dictionary<string, Claim> singleClaims = new(StringComparer.Ordinal);
private readonly List<Claim> additionalClaims = new();
private readonly HashSet<string> scopes = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> audiences = new(StringComparer.OrdinalIgnoreCase);
private string authenticationType = StellaOpsAuthenticationDefaults.AuthenticationType;
private string nameClaimType = ClaimTypes.Name;
private string roleClaimType = ClaimTypes.Role;
private string[]? cachedScopes;
private string[]? cachedAudiences;
/// <summary>
/// Adds or replaces the canonical subject identifier.
/// </summary>
public StellaOpsPrincipalBuilder WithSubject(string subject)
=> SetSingleClaim(StellaOpsClaimTypes.Subject, subject);
/// <summary>
/// Adds or replaces the canonical client identifier.
/// </summary>
public StellaOpsPrincipalBuilder WithClientId(string clientId)
=> SetSingleClaim(StellaOpsClaimTypes.ClientId, clientId);
/// <summary>
/// Adds or replaces the tenant identifier claim.
/// </summary>
public StellaOpsPrincipalBuilder WithTenant(string tenant)
=> SetSingleClaim(StellaOpsClaimTypes.Tenant, tenant);
/// <summary>
/// Adds or replaces the user display name claim.
/// </summary>
public StellaOpsPrincipalBuilder WithName(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
singleClaims[nameClaimType] = new Claim(nameClaimType, name.Trim(), ClaimValueTypes.String);
return this;
}
/// <summary>
/// Adds or replaces the identity provider claim.
/// </summary>
public StellaOpsPrincipalBuilder WithIdentityProvider(string identityProvider)
=> SetSingleClaim(StellaOpsClaimTypes.IdentityProvider, identityProvider);
/// <summary>
/// Adds or replaces the session identifier claim.
/// </summary>
public StellaOpsPrincipalBuilder WithSessionId(string sessionId)
=> SetSingleClaim(StellaOpsClaimTypes.SessionId, sessionId);
/// <summary>
/// Adds or replaces the token identifier claim.
/// </summary>
public StellaOpsPrincipalBuilder WithTokenId(string tokenId)
=> SetSingleClaim(StellaOpsClaimTypes.TokenId, tokenId);
/// <summary>
/// Adds or replaces the authentication method reference claim.
/// </summary>
public StellaOpsPrincipalBuilder WithAuthenticationMethod(string method)
=> SetSingleClaim(StellaOpsClaimTypes.AuthenticationMethod, method);
/// <summary>
/// Sets the name claim type appended when building the <see cref="ClaimsIdentity"/>.
/// </summary>
public StellaOpsPrincipalBuilder WithNameClaimType(string claimType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(claimType);
nameClaimType = claimType.Trim();
return this;
}
/// <summary>
/// Sets the role claim type appended when building the <see cref="ClaimsIdentity"/>.
/// </summary>
public StellaOpsPrincipalBuilder WithRoleClaimType(string claimType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(claimType);
roleClaimType = claimType.Trim();
return this;
}
/// <summary>
/// Sets the authentication type stamped on the <see cref="ClaimsIdentity"/>.
/// </summary>
public StellaOpsPrincipalBuilder WithAuthenticationType(string authenticationType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(authenticationType);
this.authenticationType = authenticationType.Trim();
return this;
}
/// <summary>
/// Registers the supplied scopes (normalised to lower-case, deduplicated, sorted).
/// </summary>
public StellaOpsPrincipalBuilder WithScopes(IEnumerable<string> scopes)
{
ArgumentNullException.ThrowIfNull(scopes);
foreach (var scope in scopes)
{
var normalized = StellaOpsScopes.Normalize(scope);
if (normalized is null)
{
continue;
}
if (this.scopes.Add(normalized))
{
cachedScopes = null;
}
}
return this;
}
/// <summary>
/// Registers the supplied audiences (trimmed, deduplicated, sorted).
/// </summary>
public StellaOpsPrincipalBuilder WithAudiences(IEnumerable<string> audiences)
{
ArgumentNullException.ThrowIfNull(audiences);
foreach (var audience in audiences)
{
if (string.IsNullOrWhiteSpace(audience))
{
continue;
}
if (this.audiences.Add(audience.Trim()))
{
cachedAudiences = null;
}
}
return this;
}
/// <summary>
/// Adds a single audience.
/// </summary>
public StellaOpsPrincipalBuilder WithAudience(string audience)
=> WithAudiences(new[] { audience });
/// <summary>
/// Adds an arbitrary claim (no deduplication is performed).
/// </summary>
public StellaOpsPrincipalBuilder AddClaim(string type, string value, string valueType = ClaimValueTypes.String)
{
ArgumentException.ThrowIfNullOrWhiteSpace(type);
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var trimmedType = type.Trim();
var trimmedValue = value.Trim();
additionalClaims.Add(new Claim(trimmedType, trimmedValue, valueType));
return this;
}
/// <summary>
/// Adds multiple claims (incoming claims are cloned to enforce value trimming).
/// </summary>
public StellaOpsPrincipalBuilder AddClaims(IEnumerable<Claim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
foreach (var claim in claims)
{
ArgumentNullException.ThrowIfNull(claim);
AddClaim(claim.Type, claim.Value, claim.ValueType);
}
return this;
}
/// <summary>
/// Adds an <c>iat</c> (issued at) claim using Unix time seconds.
/// </summary>
public StellaOpsPrincipalBuilder WithIssuedAt(DateTimeOffset issuedAt)
=> SetSingleClaim("iat", ToUnixTime(issuedAt));
/// <summary>
/// Adds an <c>nbf</c> (not before) claim using Unix time seconds.
/// </summary>
public StellaOpsPrincipalBuilder WithNotBefore(DateTimeOffset notBefore)
=> SetSingleClaim("nbf", ToUnixTime(notBefore));
/// <summary>
/// Adds an <c>exp</c> (expires) claim using Unix time seconds.
/// </summary>
public StellaOpsPrincipalBuilder WithExpires(DateTimeOffset expires)
=> SetSingleClaim("exp", ToUnixTime(expires));
/// <summary>
/// Returns the normalised scope list (deduplicated + sorted).
/// </summary>
public IReadOnlyCollection<string> NormalizedScopes
{
get
{
cachedScopes ??= scopes.Count == 0
? Array.Empty<string>()
: scopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
return cachedScopes;
}
}
/// <summary>
/// Returns the normalised audience list (deduplicated + sorted).
/// </summary>
public IReadOnlyCollection<string> Audiences
{
get
{
cachedAudiences ??= audiences.Count == 0
? Array.Empty<string>()
: audiences.OrderBy(static audience => audience, StringComparer.Ordinal).ToArray();
return cachedAudiences;
}
}
/// <summary>
/// Builds the immutable <see cref="ClaimsPrincipal"/> instance based on the registered data.
/// </summary>
public ClaimsPrincipal Build()
{
var claims = new List<Claim>(
singleClaims.Count +
additionalClaims.Count +
NormalizedScopes.Count * 2 +
Audiences.Count);
claims.AddRange(singleClaims.Values);
claims.AddRange(additionalClaims);
if (NormalizedScopes.Count > 0)
{
var joined = string.Join(' ', NormalizedScopes);
claims.Add(new Claim(StellaOpsClaimTypes.Scope, joined, ClaimValueTypes.String));
foreach (var scope in NormalizedScopes)
{
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope, ClaimValueTypes.String));
}
}
if (Audiences.Count > 0)
{
foreach (var audience in Audiences)
{
claims.Add(new Claim(StellaOpsClaimTypes.Audience, audience, ClaimValueTypes.String));
}
}
var identity = new ClaimsIdentity(claims, authenticationType, nameClaimType, roleClaimType);
return new ClaimsPrincipal(identity);
}
private StellaOpsPrincipalBuilder SetSingleClaim(string type, string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
var trimmedValue = value.Trim();
singleClaims[type] = new Claim(type, trimmedValue, ClaimValueTypes.String);
return this;
}
private static string ToUnixTime(DateTimeOffset value)
=> value.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Factory helpers for returning RFC 7807 problem responses using StellaOps conventions.
/// </summary>
public static class StellaOpsProblemResultFactory
{
private const string ProblemBase = "https://docs.stella-ops.org/problems";
/// <summary>
/// Produces a 401 problem response indicating authentication is required.
/// </summary>
public static ProblemHttpResult AuthenticationRequired(string? detail = null, string? instance = null)
=> Create(
StatusCodes.Status401Unauthorized,
$"{ProblemBase}/authentication-required",
"Authentication required",
detail ?? "Authentication is required to access this resource.",
instance,
"unauthorized");
/// <summary>
/// Produces a 401 problem response for invalid, expired, or revoked tokens.
/// </summary>
public static ProblemHttpResult InvalidToken(string? detail = null, string? instance = null)
=> Create(
StatusCodes.Status401Unauthorized,
$"{ProblemBase}/invalid-token",
"Invalid token",
detail ?? "The supplied access token is invalid, expired, or revoked.",
instance,
"invalid_token");
/// <summary>
/// Produces a 403 problem response when access is denied.
/// </summary>
public static ProblemHttpResult Forbidden(string? detail = null, string? instance = null)
=> Create(
StatusCodes.Status403Forbidden,
$"{ProblemBase}/forbidden",
"Forbidden",
detail ?? "The authenticated principal is not authorised to access this resource.",
instance,
"forbidden");
/// <summary>
/// Produces a 403 problem response for insufficient scopes.
/// </summary>
public static ProblemHttpResult InsufficientScope(
IReadOnlyCollection<string> requiredScopes,
IReadOnlyCollection<string>? grantedScopes = null,
string? instance = null)
{
ArgumentNullException.ThrowIfNull(requiredScopes);
var extensions = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["required_scopes"] = requiredScopes.ToArray()
};
if (grantedScopes is not null)
{
extensions["granted_scopes"] = grantedScopes.ToArray();
}
return Create(
StatusCodes.Status403Forbidden,
$"{ProblemBase}/insufficient-scope",
"Insufficient scope",
"The authenticated principal does not hold the scopes required by this resource.",
instance,
"insufficient_scope",
extensions);
}
private static ProblemHttpResult Create(
int status,
string type,
string title,
string detail,
string? instance,
string error,
IReadOnlyDictionary<string, object?>? extensions = null)
{
var problem = new ProblemDetails
{
Status = status,
Type = type,
Title = title,
Detail = detail,
Instance = instance
};
problem.Extensions["error"] = error;
problem.Extensions["error_description"] = detail;
if (extensions is not null)
{
foreach (var entry in extensions)
{
problem.Extensions[entry.Key] = entry.Value;
}
}
return TypedResults.Problem(problem);
}
}

View File

@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Feedser jobs.
/// </summary>
public const string FeedserJobsTrigger = "feedser.jobs.trigger";
/// <summary>
/// Scope required to manage Feedser merge operations.
/// </summary>
public const string FeedserMerge = "feedser.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
FeedserJobsTrigger,
FeedserMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public async Task AddStellaOpsAuthClient_ConfiguresRetryPolicy()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsAuthClient(options =>
{
options.Authority = "https://authority.test";
options.RetryDelays.Clear();
options.RetryDelays.Add(TimeSpan.FromMilliseconds(1));
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
options.AllowOfflineCacheFallback = false;
});
var recordedHandlers = new List<DelegatingHandler>();
var attemptCount = 0;
services.AddHttpClient<StellaOpsDiscoveryCache>()
.ConfigureHttpMessageHandlerBuilder(builder =>
{
recordedHandlers = new List<DelegatingHandler>(builder.AdditionalHandlers);
var responses = new Queue<Func<HttpResponseMessage>>(new[]
{
() => CreateResponse(HttpStatusCode.InternalServerError, "{}"),
() => CreateResponse(HttpStatusCode.OK, "{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}")
});
builder.PrimaryHandler = new LambdaHttpMessageHandler((_, _) =>
{
attemptCount++;
if (responses.Count == 0)
{
return Task.FromResult(CreateResponse(HttpStatusCode.OK, "{}"));
}
var factory = responses.Dequeue();
return Task.FromResult(factory());
});
});
using var provider = services.BuildServiceProvider();
var cache = provider.GetRequiredService<StellaOpsDiscoveryCache>();
var configuration = await cache.GetAsync(CancellationToken.None);
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
Assert.Equal(2, attemptCount);
Assert.NotEmpty(recordedHandlers);
Assert.Contains(recordedHandlers, handler => handler.GetType().Name.Contains("PolicyHttpMessageHandler", StringComparison.Ordinal));
}
private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string jsonContent)
{
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(jsonContent)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class LambdaHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public LambdaHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
using System;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsAuthClientOptionsTests
{
[Fact]
public void Validate_NormalizesScopes()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli",
HttpTimeout = TimeSpan.FromSeconds(15)
};
options.DefaultScopes.Add(" Feedser.Jobs.Trigger ");
options.DefaultScopes.Add("feedser.jobs.trigger");
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
options.Validate();
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsAuthClientOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_NormalizesRetryDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test"
};
options.RetryDelays.Clear();
options.RetryDelays.Add(TimeSpan.Zero);
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
options.Validate();
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
}
[Fact]
public void Validate_DisabledRetries_ProducesEmptyDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
EnableRetries = false
};
options.Validate();
Assert.Empty(options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_OfflineToleranceNegative()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
};
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,134 @@
using System;
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 StellaOpsDiscoveryCacheTests
{
[Fact]
public async Task GetAsync_UsesOfflineFallbackWithinTolerance()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var callCount = 0;
var handler = new StubHttpMessageHandler((request, _) =>
{
callCount++;
if (callCount == 1)
{
return Task.FromResult(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
}
throw new HttpRequestException("offline");
});
var httpClient = new HttpClient(handler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
DiscoveryCacheLifetime = TimeSpan.FromMinutes(1),
OfflineCacheTolerance = TimeSpan.FromMinutes(5),
AllowOfflineCacheFallback = true
};
options.Validate();
var monitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var cache = new StellaOpsDiscoveryCache(httpClient, monitor, timeProvider, NullLogger<StellaOpsDiscoveryCache>.Instance);
var configuration = await cache.GetAsync(CancellationToken.None);
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
timeProvider.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(5));
configuration = await cache.GetAsync(CancellationToken.None);
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
Assert.Equal(2, callCount);
var offlineExpiry = GetOfflineExpiry(cache);
Assert.True(offlineExpiry > timeProvider.GetUtcNow());
timeProvider.Advance(options.OfflineCacheTolerance + TimeSpan.FromSeconds(1));
Assert.True(offlineExpiry < timeProvider.GetUtcNow());
HttpRequestException? exception = null;
try
{
await cache.GetAsync(CancellationToken.None);
}
catch (HttpRequestException ex)
{
exception = ex;
}
Assert.NotNull(exception);
Assert.Equal(3, callCount);
}
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<T> : IOptionsMonitor<T>
where T : class
{
private readonly T value;
public TestOptionsMonitor(T value)
{
this.value = value;
}
public T CurrentValue => value;
public T Get(string? name) => value;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
private static DateTimeOffset GetOfflineExpiry(StellaOpsDiscoveryCache cache)
{
var field = typeof(StellaOpsDiscoveryCache).GetField("offlineExpiresAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(field);
return (DateTimeOffset)field!.GetValue(cache)!;
}
}

View File

@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsTokenClientTests
{
[Fact]
public async Task RequestPasswordToken_ReturnsResultAndCaches()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
var responses = new Queue<HttpResponseMessage>();
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"feedser.jobs.trigger\"}"));
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
return Task.FromResult(responses.Dequeue());
});
var httpClient = new HttpClient(handler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli"
};
options.DefaultScopes.Add("feedser.jobs.trigger");
options.Validate();
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
var result = await client.RequestPasswordTokenAsync("user", "pass");
Assert.Equal("abc", result.AccessToken);
Assert.Contains("feedser.jobs.trigger", result.Scopes);
await client.CacheTokenAsync("key", result.ToCacheEntry());
var cached = await client.GetCachedTokenAsync("key");
Assert.NotNull(cached);
Assert.Equal("abc", cached!.AccessToken);
var jwks = await client.GetJsonWebKeySetAsync();
Assert.Empty(jwks.Keys);
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions value;
public TestOptionsMonitor(TOptions value)
{
this.value = value;
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class TokenCacheTests
{
[Fact]
public async Task InMemoryTokenCache_ExpiresEntries()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
var entry = new StellaOpsTokenCacheEntry("token", "Bearer", timeProvider.GetUtcNow() + TimeSpan.FromSeconds(10), new[] { "scope" });
await cache.SetAsync("key", entry);
var retrieved = await cache.GetAsync("key");
Assert.NotNull(retrieved);
timeProvider.Advance(TimeSpan.FromSeconds(12));
retrieved = await cache.GetAsync("key");
Assert.Null(retrieved);
}
[Fact]
public async Task FileTokenCache_PersistsEntries()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var cache = new FileTokenCache(directory, timeProvider, TimeSpan.Zero);
var entry = new StellaOpsTokenCacheEntry("token", "Bearer", timeProvider.GetUtcNow() + TimeSpan.FromMinutes(5), new[] { "scope" });
await cache.SetAsync("key", entry);
var retrieved = await cache.GetAsync("key");
Assert.NotNull(retrieved);
Assert.Equal("token", retrieved!.AccessToken);
await cache.RemoveAsync("key");
retrieved = await cache.GetAsync("key");
Assert.Null(retrieved);
}
finally
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,122 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Auth.Client;
/// <summary>
/// File-based token cache suitable for CLI/offline usage.
/// </summary>
public sealed class FileTokenCache : IStellaOpsTokenCache
{
private readonly string cacheDirectory;
private readonly TimeProvider timeProvider;
private readonly TimeSpan expirationSkew;
private readonly ILogger<FileTokenCache>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
public FileTokenCache(string cacheDirectory, TimeProvider? timeProvider = null, TimeSpan? expirationSkew = null, ILogger<FileTokenCache>? logger = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
this.cacheDirectory = cacheDirectory;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.expirationSkew = expirationSkew ?? TimeSpan.FromSeconds(30);
this.logger = logger;
}
public async ValueTask<StellaOpsTokenCacheEntry?> GetAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
var path = GetPath(key);
if (!File.Exists(path))
{
return null;
}
try
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
var entry = await JsonSerializer.DeserializeAsync<StellaOpsTokenCacheEntry>(stream, serializerOptions, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
return null;
}
entry = entry.NormalizeScopes();
if (entry.IsExpired(timeProvider, expirationSkew))
{
await RemoveInternalAsync(path).ConfigureAwait(false);
return null;
}
return entry;
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
logger?.LogWarning(ex, "Failed to read token cache entry '{CacheKey}'.", key);
return null;
}
}
public async ValueTask SetAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(entry);
Directory.CreateDirectory(cacheDirectory);
var path = GetPath(key);
var payload = entry.NormalizeScopes();
try
{
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous);
await JsonSerializer.SerializeAsync(stream, payload, serializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger?.LogWarning(ex, "Failed to persist token cache entry '{CacheKey}'.", key);
}
}
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
var path = GetPath(key);
return new ValueTask(RemoveInternalAsync(path));
}
private async Task RemoveInternalAsync(string path)
{
try
{
if (File.Exists(path))
{
await Task.Run(() => File.Delete(path)).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger?.LogDebug(ex, "Failed to remove cache file '{Path}'.", path);
}
}
private string GetPath(string key)
{
using var sha = SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(key);
var hash = Convert.ToHexString(sha.ComputeHash(bytes));
return Path.Combine(cacheDirectory, $"{hash}.json");
}
}

View File

@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Client;
/// <summary>
/// Abstraction for caching StellaOps tokens.
/// </summary>
public interface IStellaOpsTokenCache
{
/// <summary>
/// Retrieves a cached token entry, if present.
/// </summary>
ValueTask<StellaOpsTokenCacheEntry?> GetAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Stores or updates a token entry for the specified key.
/// </summary>
ValueTask SetAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Removes the cached entry for the specified key.
/// </summary>
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,41 @@
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, CancellationToken cancellationToken = default);
/// <summary>
/// Requests an access token using the client credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = 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

@@ -0,0 +1,58 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Client;
/// <summary>
/// In-memory token cache suitable for service scenarios.
/// </summary>
public sealed class InMemoryTokenCache : IStellaOpsTokenCache
{
private readonly ConcurrentDictionary<string, StellaOpsTokenCacheEntry> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly Func<StellaOpsTokenCacheEntry, StellaOpsTokenCacheEntry> normalizer;
private readonly TimeSpan expirationSkew;
public InMemoryTokenCache(TimeProvider? timeProvider = null, TimeSpan? expirationSkew = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
this.expirationSkew = expirationSkew ?? TimeSpan.FromSeconds(30);
normalizer = static entry => entry.NormalizeScopes();
}
public ValueTask<StellaOpsTokenCacheEntry?> GetAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (!entries.TryGetValue(key, out var entry))
{
return ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
}
if (entry.IsExpired(timeProvider, expirationSkew))
{
entries.TryRemove(key, out _);
return ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
}
return ValueTask.FromResult<StellaOpsTokenCacheEntry?>(entry);
}
public ValueTask SetAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(entry);
entries[key] = normalizer(entry);
return ValueTask.CompletedTask;
}
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
entries.TryRemove(key, out _);
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
# StellaOps.Auth.Client
Typed OpenID Connect client used by StellaOps services, agents, and tooling to talk to **StellaOps Authority**. It provides:
- Discovery + JWKS caching with deterministic refresh windows.
- Password and client-credential flows with token cache abstractions.
- Configurable HTTP retry/backoff policies (Polly) and offline fallback support for air-gapped deployments.
See `docs/dev/32_AUTH_CLIENT_GUIDE.md` in the repository for integration guidance, option descriptions, and rollout checklists.

View File

@@ -0,0 +1,115 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
namespace StellaOps.Auth.Client;
/// <summary>
/// DI helpers for the StellaOps auth client.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the StellaOps auth client with the provided configuration.
/// </summary>
public static IServiceCollection AddStellaOpsAuthClient(this IServiceCollection services, Action<StellaOpsAuthClientOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.AddOptions<StellaOpsAuthClientOptions>()
.Configure(configure)
.PostConfigure(static options => options.Validate());
services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
return services;
}
/// <summary>
/// Registers a file-backed token cache implementation.
/// </summary>
public static IServiceCollection AddStellaOpsFileTokenCache(this IServiceCollection services, string cacheDirectory)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
services.Replace(ServiceDescriptor.Singleton<IStellaOpsTokenCache>(provider =>
{
var logger = provider.GetService<Microsoft.Extensions.Logging.ILogger<FileTokenCache>>();
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
return new FileTokenCache(cacheDirectory, TimeProvider.System, options.ExpirationSkew, logger);
}));
return services;
}
private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = options.NormalizedRetryDelays;
if (delays.Count == 0)
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var logger = provider.GetService<ILoggerFactory>()?.CreateLogger("StellaOps.Auth.Client.HttpRetry");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
if (logger is null)
{
return;
}
if (outcome.Exception is not null)
{
logger.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) after exception; waiting {Delay}.",
attempt,
delays.Count,
delay);
}
else
{
logger.LogWarning(
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) due to status {StatusCode}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Result!.StatusCode,
delay);
}
});
}
}

View File

@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.Client</PackageId>
<Description>Typed OAuth/OpenID client for StellaOps Authority with caching, retries, and token helpers.</Description>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageTags>stellaops;authentication;authority;oauth2;client</PackageTags>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<PackageReadmeFile>README.NuGet.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<None Include="README.NuGet.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.Client;
/// <summary>
/// Options controlling the StellaOps authentication client.
/// </summary>
public sealed class StellaOpsAuthClientOptions
{
private static readonly TimeSpan[] DefaultRetryDelays =
{
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5)
};
private static readonly TimeSpan DefaultOfflineTolerance = TimeSpan.FromMinutes(10);
private readonly List<string> scopes = new();
private readonly List<TimeSpan> retryDelays = new(DefaultRetryDelays);
/// <summary>
/// Authority (issuer) base URL.
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// OAuth client identifier (optional for password flow).
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// OAuth client secret (optional for public clients).
/// </summary>
public string? ClientSecret { get; set; }
/// <summary>
/// Default scopes requested for flows that do not explicitly override them.
/// </summary>
public IList<string> DefaultScopes => scopes;
/// <summary>
/// Retry delays applied by HTTP retry policy (empty uses defaults).
/// </summary>
public IList<TimeSpan> RetryDelays => retryDelays;
/// <summary>
/// Gets or sets a value indicating whether HTTP retry policies are enabled.
/// </summary>
public bool EnableRetries { get; set; } = true;
/// <summary>
/// Timeout applied to discovery and token HTTP requests.
/// </summary>
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Lifetime of cached discovery metadata.
/// </summary>
public TimeSpan DiscoveryCacheLifetime { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Lifetime of cached JWKS metadata.
/// </summary>
public TimeSpan JwksCacheLifetime { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Buffer applied when determining cache expiration (default: 30 seconds).
/// </summary>
public TimeSpan ExpirationSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable.
/// </summary>
public bool AllowOfflineCacheFallback { get; set; } = true;
/// <summary>
/// Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed.
/// </summary>
public TimeSpan OfflineCacheTolerance { get; set; } = DefaultOfflineTolerance;
/// <summary>
/// Parsed Authority URI (populated after validation).
/// </summary>
public Uri AuthorityUri { get; private set; } = null!;
/// <summary>
/// Normalised scope list (populated after validation).
/// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary>
/// Normalised retry delays (populated after validation).
/// </summary>
public IReadOnlyList<TimeSpan> NormalizedRetryDelays { get; private set; } = Array.Empty<TimeSpan>();
/// <summary>
/// Validates required values and normalises scope entries.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Auth client requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Auth client Authority must be an absolute URI.");
}
if (HttpTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("Auth client HTTP timeout must be greater than zero.");
}
if (DiscoveryCacheLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("Discovery cache lifetime must be greater than zero.");
}
if (JwksCacheLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("JWKS cache lifetime must be greater than zero.");
}
if (ExpirationSkew < TimeSpan.Zero || ExpirationSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Expiration skew must be between 0 seconds and 5 minutes.");
}
if (OfflineCacheTolerance < TimeSpan.Zero)
{
throw new InvalidOperationException("Offline cache tolerance must be greater than or equal to zero.");
}
AuthorityUri = authorityUri;
NormalizedScopes = NormalizeScopes(scopes);
NormalizedRetryDelays = EnableRetries ? NormalizeRetryDelays(retryDelays) : Array.Empty<TimeSpan>();
}
private static IReadOnlyList<string> NormalizeScopes(IList<string> values)
{
if (values.Count == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(StringComparer.Ordinal);
for (var index = values.Count - 1; index >= 0; index--)
{
var entry = values[index];
if (string.IsNullOrWhiteSpace(entry))
{
values.RemoveAt(index);
continue;
}
var normalized = StellaOpsScopes.Normalize(entry);
if (normalized is null)
{
values.RemoveAt(index);
continue;
}
if (!unique.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
return values.Count == 0
? Array.Empty<string>()
: values.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<TimeSpan> NormalizeRetryDelays(IList<TimeSpan> values)
{
for (var index = values.Count - 1; index >= 0; index--)
{
var delay = values[index];
if (delay <= TimeSpan.Zero)
{
values.RemoveAt(index);
}
}
if (values.Count == 0)
{
foreach (var delay in DefaultRetryDelays)
{
values.Add(delay);
}
}
return values.ToArray();
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Auth.Client;
/// <summary>
/// Caches Authority discovery metadata.
/// </summary>
public sealed class StellaOpsDiscoveryCache
{
private readonly HttpClient httpClient;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsDiscoveryCache>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
private OpenIdConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
public StellaOpsDiscoveryCache(HttpClient httpClient, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, TimeProvider? timeProvider = null, ILogger<StellaOpsDiscoveryCache>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async Task<OpenIdConfiguration> GetAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
if (cachedConfiguration is not null && now < cacheExpiresAt)
{
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var discoveryUri = new Uri(options.AuthorityUri, ".well-known/openid-configuration");
logger?.LogDebug("Fetching StellaOps discovery document from {DiscoveryUri}.", discoveryUri);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, discoveryUri);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var document = await JsonSerializer.DeserializeAsync<DiscoveryDocument>(stream, serializerOptions, cancellationToken).ConfigureAwait(false);
if (document is null)
{
throw new InvalidOperationException("Authority discovery document is empty.");
}
if (string.IsNullOrWhiteSpace(document.TokenEndpoint))
{
throw new InvalidOperationException("Authority discovery document does not expose token_endpoint.");
}
if (string.IsNullOrWhiteSpace(document.JwksUri))
{
throw new InvalidOperationException("Authority discovery document does not expose jwks_uri.");
}
var configuration = new OpenIdConfiguration(
new Uri(document.TokenEndpoint, UriKind.Absolute),
new Uri(document.JwksUri, UriKind.Absolute));
cachedConfiguration = configuration;
cacheExpiresAt = now + options.DiscoveryCacheLifetime;
offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance;
return configuration;
}
catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception))
{
return cachedConfiguration!;
}
}
private sealed record DiscoveryDocument(
[property: System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] string? TokenEndpoint,
[property: System.Text.Json.Serialization.JsonPropertyName("jwks_uri")] string? JwksUri);
private static bool IsOfflineCandidate(Exception exception, CancellationToken cancellationToken)
{
if (exception is HttpRequestException)
{
return true;
}
if (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
return true;
}
if (exception is TimeoutException)
{
return true;
}
return false;
}
private bool TryUseOfflineFallback(StellaOpsAuthClientOptions options, DateTimeOffset now, Exception exception)
{
if (!options.AllowOfflineCacheFallback || cachedConfiguration is null)
{
return false;
}
if (options.OfflineCacheTolerance <= TimeSpan.Zero)
{
return false;
}
if (offlineExpiresAt == DateTimeOffset.MinValue)
{
return false;
}
if (now >= offlineExpiresAt)
{
return false;
}
logger?.LogWarning(exception, "Discovery document fetch failed; reusing cached configuration until {FallbackExpiresAt}.", offlineExpiresAt);
return true;
}
}
/// <summary>
/// Minimal OpenID Connect configuration representation.
/// </summary>
public sealed record OpenIdConfiguration(Uri TokenEndpoint, Uri JwksEndpoint);

View File

@@ -0,0 +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.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Caches JWKS documents for Authority.
/// </summary>
public sealed class StellaOpsJwksCache
{
private readonly HttpClient httpClient;
private readonly StellaOpsDiscoveryCache discoveryCache;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsJwksCache>? logger;
private JsonWebKeySet? cachedSet;
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
public StellaOpsJwksCache(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
TimeProvider? timeProvider = null,
ILogger<StellaOpsJwksCache>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async Task<JsonWebKeySet> GetAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
if (cachedSet is not null && now < cacheExpiresAt)
{
return cachedSet;
}
var options = optionsMonitor.CurrentValue;
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
logger?.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksEndpoint);
try
{
using var response = await httpClient.GetAsync(configuration.JwksEndpoint, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
cachedSet = new JsonWebKeySet(json);
cacheExpiresAt = now + options.JwksCacheLifetime;
offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance;
return cachedSet;
}
catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception))
{
return cachedSet!;
}
}
private static bool IsOfflineCandidate(Exception exception, CancellationToken cancellationToken)
{
if (exception is HttpRequestException)
{
return true;
}
if (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
return true;
}
if (exception is TimeoutException)
{
return true;
}
return false;
}
private bool TryUseOfflineFallback(StellaOpsAuthClientOptions options, DateTimeOffset now, Exception exception)
{
if (!options.AllowOfflineCacheFallback || cachedSet is null)
{
return false;
}
if (options.OfflineCacheTolerance <= TimeSpan.Zero)
{
return false;
}
if (offlineExpiresAt == DateTimeOffset.MinValue)
{
return false;
}
if (now >= offlineExpiresAt)
{
return false;
}
logger?.LogWarning(exception, "JWKS fetch failed; reusing cached keys until {FallbackExpiresAt}.", offlineExpiresAt);
return true;
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Auth.Client;
/// <summary>
/// Represents a cached token entry.
/// </summary>
public sealed record StellaOpsTokenCacheEntry(
string AccessToken,
string TokenType,
DateTimeOffset ExpiresAtUtc,
IReadOnlyList<string> Scopes,
string? RefreshToken = null,
string? IdToken = null,
IReadOnlyDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Determines whether the token is expired given the provided <see cref="TimeProvider"/>.
/// </summary>
public bool IsExpired(TimeProvider timeProvider, TimeSpan? skew = null)
{
ArgumentNullException.ThrowIfNull(timeProvider);
var now = timeProvider.GetUtcNow();
var buffer = skew ?? TimeSpan.Zero;
return now >= ExpiresAtUtc - buffer;
}
/// <summary>
/// Creates a copy with scopes normalised.
/// </summary>
public StellaOpsTokenCacheEntry NormalizeScopes()
{
if (Scopes.Count == 0)
{
return this;
}
var normalized = Scopes
.Where(scope => !string.IsNullOrWhiteSpace(scope))
.Select(scope => scope.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(scope => scope, StringComparer.Ordinal)
.ToArray();
return this with { Scopes = normalized };
}
}

View File

@@ -0,0 +1,205 @@
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, 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);
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = 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);
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

@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Client;
/// <summary>
/// Represents an issued token with metadata.
/// </summary>
public sealed record StellaOpsTokenResult(
string AccessToken,
string TokenType,
DateTimeOffset ExpiresAtUtc,
IReadOnlyList<string> Scopes,
string? RefreshToken = null,
string? IdToken = null,
string? RawResponse = null)
{
/// <summary>
/// Converts the result to a cache entry.
/// </summary>
public StellaOpsTokenCacheEntry ToCacheEntry()
=> new(AccessToken, TokenType, ExpiresAtUtc, Scopes, RefreshToken, IdToken);
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.example",
["Authority:ResourceServer:Audiences:0"] = "api://feedser",
["Authority:ResourceServer:RequiredScopes:0"] = "feedser.jobs.trigger",
["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsResourceServerAuthentication(configuration);
using var provider = services.BuildServiceProvider();
var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
Assert.NotNull(jwtOptions.Authority);
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
Assert.Contains("api://feedser", jwtOptions.TokenValidationParameters.ValidAudiences);
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
Assert.Equal(new[] { "feedser.jobs.trigger" }, resourceOptions.NormalizedScopes);
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
using System;
using System.Net;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerOptionsTests
{
[Fact]
public void Validate_NormalisesCollections()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
BackchannelTimeout = TimeSpan.FromSeconds(10),
TokenClockSkew = TimeSpan.FromSeconds(30)
};
options.Audiences.Add(" api://feedser ");
options.Audiences.Add("api://feedser");
options.Audiences.Add("api://feedser-admin");
options.RequiredScopes.Add(" Feedser.Jobs.Trigger ");
options.RequiredScopes.Add("feedser.jobs.trigger");
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
options.BypassNetworks.Add("127.0.0.1/32");
options.BypassNetworks.Add(" 127.0.0.1/32 ");
options.BypassNetworks.Add("::1/128");
options.Validate();
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
Assert.Equal(new[] { "api://feedser", "api://feedser-admin" }, options.Audiences);
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsResourceServerOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithScopes(new[] { StellaOpsScopes.FeedserJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.BypassNetworks.Add("127.0.0.1/32");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.FeedserJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress)
{
var accessor = new HttpContextAccessor();
var httpContext = new DefaultHttpContext();
httpContext.Connection.RemoteIpAddress = remoteAddress;
accessor.HttpContext = httpContext;
var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
var handler = new StellaOpsScopeAuthorizationHandler(
accessor,
bypassEvaluator,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,9 @@
# StellaOps.Auth.ServerIntegration
ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**:
- `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies.
- Network bypass mask evaluation for on-host automation.
- Consistent `ProblemDetails` responses and policy helpers shared with Feedser/Backend services.
Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration.

View File

@@ -0,0 +1,88 @@
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>();
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<IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, 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;
});
return services;
}
}

View File

@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.ServerIntegration</PackageId>
<Description>ASP.NET server integration helpers for StellaOps Authority, including JWT validation and bypass masks.</Description>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageTags>stellaops;authentication;authority;aspnet</PackageTags>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<PackageReadmeFile>README.NuGet.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-rc.1.25451.107" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="README.NuGet.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>StellaOps.Auth.ServerIntegration.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,56 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Extension methods for configuring StellaOps authorisation policies.
/// </summary>
public static class StellaOpsAuthorizationPolicyBuilderExtensions
{
/// <summary>
/// Requires the specified scopes using the StellaOps scope requirement.
/// </summary>
public static AuthorizationPolicyBuilder RequireStellaOpsScopes(
this AuthorizationPolicyBuilder builder,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(builder);
var requirement = new StellaOpsScopeRequirement(scopes);
builder.AddRequirements(requirement);
builder.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
return builder;
}
/// <summary>
/// Registers a named policy that enforces the provided scopes.
/// </summary>
public static void AddStellaOpsScopePolicy(
this AuthorizationOptions options,
string policyName,
params string[] scopes)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(policyName);
options.AddPolicy(policyName, policy =>
{
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
});
}
/// <summary>
/// Adds the scope handler to the DI container.
/// </summary>
public static IServiceCollection AddStellaOpsScopeHandler(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IAuthorizationHandler, StellaOpsScopeAuthorizationHandler>();
return services;
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Evaluates whether a request qualifies for network-based bypass.
/// </summary>
public sealed class StellaOpsBypassEvaluator
{
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly ILogger<StellaOpsBypassEvaluator> logger;
public StellaOpsBypassEvaluator(
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
ILogger<StellaOpsBypassEvaluator> logger)
{
this.optionsMonitor = optionsMonitor;
this.logger = logger;
}
public bool ShouldBypass(HttpContext context, IReadOnlyCollection<string> requiredScopes)
{
ArgumentNullException.ThrowIfNull(context);
var options = optionsMonitor.CurrentValue;
var matcher = options.BypassMatcher;
if (matcher.IsEmpty)
{
return false;
}
var remoteAddress = context.Connection.RemoteIpAddress;
if (remoteAddress is null)
{
logger.LogDebug("Bypass skipped because remote IP address is unavailable.");
return false;
}
if (!matcher.IsAllowed(remoteAddress))
{
return false;
}
if (context.Request.Headers.ContainsKey("Authorization"))
{
logger.LogDebug("Bypass skipped because Authorization header is present for {RemoteIp}.", remoteAddress);
return false;
}
logger.LogInformation(
"Granting StellaOps bypass for remote {RemoteIp}; required scopes {RequiredScopes}.",
remoteAddress,
string.Join(", ", requiredScopes));
return true;
}
}

View File

@@ -0,0 +1,152 @@
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> 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>
/// 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>
/// 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 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.");
}
AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false);
NormalizeList(requiredScopes, toLower: true);
NormalizeList(bypassNetworks, toLower: false);
NormalizedScopes = requiredScopes.Count == 0
? Array.Empty<string>()
: requiredScopes.OrderBy(static scope => scope, 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

@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Handles <see cref="StellaOpsScopeRequirement"/> evaluation.
/// </summary>
internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<StellaOpsScopeRequirement>
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly StellaOpsBypassEvaluator bypassEvaluator;
private readonly ILogger<StellaOpsScopeAuthorizationHandler> logger;
public StellaOpsScopeAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
StellaOpsBypassEvaluator bypassEvaluator,
ILogger<StellaOpsScopeAuthorizationHandler> logger)
{
this.httpContextAccessor = httpContextAccessor;
this.bypassEvaluator = bypassEvaluator;
this.logger = logger;
}
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
StellaOpsScopeRequirement requirement)
{
HashSet<string>? userScopes = null;
if (context.User?.Identity?.IsAuthenticated == true)
{
userScopes = ExtractScopes(context.User);
foreach (var scope in requirement.RequiredScopes)
{
if (userScopes.Contains(scope))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, requirement.RequiredScopes))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (logger.IsEnabled(LogLevel.Debug))
{
var required = string.Join(", ", requirement.RequiredScopes);
var principalScopes = userScopes is null || userScopes.Count == 0
? "(none)"
: string.Join(", ", userScopes);
logger.LogDebug(
"Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Remote={Remote}",
required,
principalScopes,
httpContext?.Connection.RemoteIpAddress);
}
return Task.CompletedTask;
}
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
{
var scopes = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
scopes.Add(claim.Value);
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (normalized is not null)
{
scopes.Add(normalized);
}
}
}
return scopes;
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Authorisation requirement enforcing StellaOps scope membership.
/// </summary>
public sealed class StellaOpsScopeRequirement : IAuthorizationRequirement
{
/// <summary>
/// Initialises a new instance of the <see cref="StellaOpsScopeRequirement"/> class.
/// </summary>
/// <param name="scopes">Scopes that satisfy the requirement.</param>
public StellaOpsScopeRequirement(IEnumerable<string> scopes)
{
ArgumentNullException.ThrowIfNull(scopes);
var normalized = new HashSet<string>(StringComparer.Ordinal);
foreach (var scope in scopes)
{
var value = StellaOpsScopes.Normalize(scope);
if (value is null)
{
continue;
}
normalized.Add(value);
}
if (normalized.Count == 0)
{
throw new ArgumentException("At least one scope must be provided.", nameof(scopes));
}
RequiredScopes = normalized.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
/// <summary>
/// Gets the required scopes.
/// </summary>
public IReadOnlyCollection<string> RequiredScopes { get; }
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
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 provisioning = new StandardClientProvisioningStore("standard", store);
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);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.IO;
using StellaOps.Authority.Plugin.Standard;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginOptionsTests
{
[Fact]
public void Validate_AllowsBootstrapWhenCredentialsProvided()
{
var options = new StandardPluginOptions
{
BootstrapUser = new BootstrapUserOptions
{
Username = "admin",
Password = "Bootstrap1!",
RequirePasswordReset = true
}
};
options.Validate("standard");
}
[Fact]
public void Validate_Throws_WhenBootstrapUserIncomplete()
{
var options = new StandardPluginOptions
{
BootstrapUser = new BootstrapUserOptions
{
Username = "admin",
Password = null
}
};
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
Assert.Contains("bootstrapUser", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenLockoutWindowMinutesInvalid()
{
var options = new StandardPluginOptions
{
Lockout = new LockoutOptions
{
Enabled = true,
MaxAttempts = 5,
WindowMinutes = 0
}
};
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
Assert.Contains("lockout.windowMinutes", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Normalize_ResolvesRelativeTokenSigningDirectory()
{
var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(configDir);
try
{
var configPath = Path.Combine(configDir, "standard.yaml");
var options = new StandardPluginOptions
{
TokenSigning = { KeyDirectory = "../keys" }
};
options.Normalize(configPath);
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
}
finally
{
if (Directory.Exists(configDir))
{
Directory.Delete(configDir, recursive: true);
}
}
}
[Fact]
public void Normalize_PreservesAbsoluteTokenSigningDirectory()
{
var absolute = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"), "keys");
var options = new StandardPluginOptions
{
TokenSigning = { KeyDirectory = absolute }
};
options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml"));
Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
}
}

View File

@@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Driver;
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;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginRegistrarTests
{
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "8",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false",
["lockout:enabled"] = "false",
["bootstrapUser:username"] = "bootstrap",
["bootstrapUser:password"] = "Bootstrap1!",
["bootstrapUser:requirePasswordReset"] = "true"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
var provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>();
foreach (var hosted in hostedServices)
{
if (hosted is StandardPluginBootstrapper bootstrapper)
{
await bootstrapper.StartAsync(CancellationToken.None);
}
}
var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
Assert.Equal("standard", plugin.Type);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.False(plugin.Capabilities.SupportsMfa);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.True(verification.User?.RequiresPasswordReset);
}
[Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-capabilities");
var configuration = new ConfigurationBuilder().Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
Array.Empty<string>(),
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
Assert.True(plugin.Capabilities.SupportsPassword);
}
[Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-bootstrap-validation");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["bootstrapUser:username"] = "bootstrap"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IIdentityProviderPlugin>());
}
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["tokenSigning:keyDirectory"] = "../keys"
})
.Build();
var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(configDir);
try
{
var configPath = Path.Combine(configDir, "standard.yaml");
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
}
finally
{
if (Directory.Exists(configDir))
{
Directory.Delete(configDir, recursive: true);
}
}
}
}
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
=> ValueTask.FromResult(clients.Remove(clientId));
}

View File

@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardUserCredentialStoreTests : IAsyncLifetime
{
private readonly MongoDbRunner runner;
private readonly IMongoDatabase database;
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
public StandardUserCredentialStoreTests()
{
runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
database = client.GetDatabase("authority-tests");
options = new StandardPluginOptions
{
PasswordPolicy = new PasswordPolicyOptions
{
MinimumLength = 8,
RequireDigit = true,
RequireLowercase = true,
RequireUppercase = true,
RequireSymbol = false
},
Lockout = new LockoutOptions
{
Enabled = true,
MaxAttempts = 2,
WindowMinutes = 1
}
};
store = new StandardUserCredentialStore(
"standard",
database,
options,
new Pbkdf2PasswordHasher(),
NullLogger<StandardUserCredentialStore>.Instance);
}
[Fact]
public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials()
{
var registration = new AuthorityUserRegistration(
"alice",
"Password1!",
"Alice",
null,
false,
new[] { "admin" },
new Dictionary<string, string?>());
var upsert = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(upsert.Succeeded);
var result = await store.VerifyPasswordAsync("alice", "Password1!", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("alice", result.User?.Username);
}
[Fact]
public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures()
{
await store.UpsertUserAsync(
new AuthorityUserRegistration(
"bob",
"Password1!",
"Bob",
null,
false,
new[] { "operator" },
new Dictionary<string, string?>()),
CancellationToken.None);
var first = await store.VerifyPasswordAsync("bob", "wrong", CancellationToken.None);
Assert.False(first.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, first.FailureCode);
var second = await store.VerifyPasswordAsync("bob", "stillwrong", CancellationToken.None);
Assert.False(second.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, second.FailureCode);
Assert.NotNull(second.RetryAfter);
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
# Plugin Team Charter
## Mission
Own the Mongo-backed Standard identity provider plug-in and shared Authority plug-in contracts. Deliver secure credential flows, configuration validation, and documentation that help other identity providers integrate cleanly.
## Responsibilities
- Maintain `StellaOps.Authority.Plugin.Standard` and related test projects.
- Coordinate schema/option changes with Authority Core and Docs guilds.
- Ensure plugin options remain deterministic and offline-friendly.
- Surface open work on `TASKS.md`; update statuses (TODO/DOING/DONE/BLOCKED/REVIEW).
## Key Paths
- `StandardPluginOptions` & registrar wiring
- `StandardUserCredentialStore` (Mongo persistence + lockouts)
- `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`
## Coordination
- Team 2 (Authority Core) for handler integration.
- Security Guild for password hashing, audit, revocation.
- Docs Guild for developer guide polish and diagrams.

View File

@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
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 IOptionsMonitor<StandardPluginOptions> optionsMonitor;
private readonly StandardUserCredentialStore credentialStore;
private readonly ILogger<StandardPluginBootstrapper> logger;
public StandardPluginBootstrapper(
string pluginName,
IOptionsMonitor<StandardPluginOptions> optionsMonitor,
StandardUserCredentialStore credentialStore,
ILogger<StandardPluginBootstrapper> logger)
{
this.pluginName = pluginName;
this.optionsMonitor = optionsMonitor;
this.credentialStore = credentialStore;
this.logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
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

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Standard.Tests")]

View File

@@ -0,0 +1,113 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Authority.Plugin.Standard.Security;
internal interface IPasswordHasher
{
string Hash(string password);
PasswordVerificationResult Verify(string password, string hashedPassword);
}
internal enum PasswordVerificationResult
{
Failed,
Success,
SuccessRehashNeeded
}
internal sealed class Pbkdf2PasswordHasher : IPasswordHasher
{
private const int SaltSize = 16;
private const int HashSize = 32;
private const int Iterations = 210_000;
private const string Header = "PBKDF2";
public string Hash(string password)
{
if (string.IsNullOrEmpty(password))
{
throw new ArgumentException("Password is required.", nameof(password));
}
Span<byte> salt = stackalloc byte[SaltSize];
RandomNumberGenerator.Fill(salt);
Span<byte> hash = stackalloc byte[HashSize];
var derived = Rfc2898DeriveBytes.Pbkdf2(password, salt.ToArray(), Iterations, HashAlgorithmName.SHA256, HashSize);
derived.CopyTo(hash);
var payload = new byte[1 + SaltSize + HashSize];
payload[0] = 0x01; // version
salt.CopyTo(payload.AsSpan(1));
hash.CopyTo(payload.AsSpan(1 + SaltSize));
var builder = new StringBuilder();
builder.Append(Header);
builder.Append('.');
builder.Append(Iterations);
builder.Append('.');
builder.Append(Convert.ToBase64String(payload));
return builder.ToString();
}
public PasswordVerificationResult Verify(string password, string hashedPassword)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashedPassword))
{
return PasswordVerificationResult.Failed;
}
var parts = hashedPassword.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3 || !string.Equals(parts[0], Header, StringComparison.Ordinal))
{
return PasswordVerificationResult.Failed;
}
if (!int.TryParse(parts[1], out var iterations))
{
return PasswordVerificationResult.Failed;
}
byte[] payload;
try
{
payload = Convert.FromBase64String(parts[2]);
}
catch (FormatException)
{
return PasswordVerificationResult.Failed;
}
if (payload.Length != 1 + SaltSize + HashSize)
{
return PasswordVerificationResult.Failed;
}
var version = payload[0];
if (version != 0x01)
{
return PasswordVerificationResult.Failed;
}
var salt = new byte[SaltSize];
Array.Copy(payload, 1, salt, 0, SaltSize);
var expectedHash = new byte[HashSize];
Array.Copy(payload, 1 + SaltSize, expectedHash, 0, HashSize);
var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, HashSize);
var success = CryptographicOperations.FixedTimeEquals(expectedHash, actualHash);
if (!success)
{
return PasswordVerificationResult.Failed;
}
return iterations < Iterations
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Success;
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardClaimsEnricher : IClaimsEnricher
{
public ValueTask EnrichAsync(
ClaimsIdentity identity,
AuthorityClaimsEnrichmentContext context,
CancellationToken cancellationToken)
{
if (identity is null)
{
throw new ArgumentNullException(nameof(identity));
}
if (context.User is { } user)
{
foreach (var role in user.Roles.Where(static r => !string.IsNullOrWhiteSpace(r)))
{
if (!identity.HasClaim(ClaimTypes.Role, role))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
}
foreach (var pair in user.Attributes)
{
if (!string.IsNullOrWhiteSpace(pair.Key) && !identity.HasClaim(pair.Key, pair.Value ?? string.Empty))
{
identity.AddClaim(new Claim(pair.Key, pair.Value ?? string.Empty));
}
}
}
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardIdentityProviderPlugin : IIdentityProviderPlugin
{
private readonly ILogger<StandardIdentityProviderPlugin> logger;
public StandardIdentityProviderPlugin(
AuthorityPluginContext context,
StandardUserCredentialStore credentialStore,
StandardClientProvisioningStore clientProvisioningStore,
IClaimsEnricher claimsEnricher,
ILogger<StandardIdentityProviderPlugin> logger)
{
Context = context ?? throw new ArgumentNullException(nameof(context));
Credentials = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
ClientProvisioning = clientProvisioningStore ?? throw new ArgumentNullException(nameof(clientProvisioningStore));
ClaimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(context.Manifest.Capabilities);
if (!manifestCapabilities.SupportsPassword)
{
this.logger.LogWarning(
"Standard Authority plugin '{PluginName}' manifest does not declare the 'password' capability. Forcing password support.",
Context.Manifest.Name);
}
Capabilities = manifestCapabilities with { SupportsPassword = true };
}
public string Name => Context.Manifest.Name;
public string Type => Context.Manifest.Type;
public AuthorityPluginContext Context { get; }
public IUserCredentialStore Credentials { get; }
public IClaimsEnricher ClaimsEnricher { get; }
public IClientProvisioningStore? ClientProvisioning { get; }
public AuthorityIdentityProviderCapabilities Capabilities { get; }
public async ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
{
try
{
var store = (StandardUserCredentialStore)Credentials;
return await store.CheckHealthAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Standard Authority plugin '{PluginName}' health check failed.", Name);
return AuthorityPluginHealthResult.Unavailable(ex.Message);
}
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.IO;
namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginOptions
{
public BootstrapUserOptions? BootstrapUser { get; set; }
public PasswordPolicyOptions PasswordPolicy { get; set; } = new();
public LockoutOptions Lockout { get; set; } = new();
public TokenSigningOptions TokenSigning { get; set; } = new();
public void Normalize(string configPath)
{
TokenSigning.Normalize(configPath);
}
public void Validate(string pluginName)
{
BootstrapUser?.Validate(pluginName);
PasswordPolicy.Validate(pluginName);
Lockout.Validate(pluginName);
}
}
internal sealed class BootstrapUserOptions
{
public string? Username { get; set; }
public string? Password { get; set; }
public bool RequirePasswordReset { get; set; } = true;
public bool IsConfigured => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password);
public void Validate(string pluginName)
{
var hasUsername = !string.IsNullOrWhiteSpace(Username);
var hasPassword = !string.IsNullOrWhiteSpace(Password);
if (hasUsername ^ hasPassword)
{
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires both bootstrapUser.username and bootstrapUser.password when configuring a bootstrap user.");
}
}
}
internal sealed class PasswordPolicyOptions
{
public int MinimumLength { get; set; } = 12;
public bool RequireUppercase { get; set; } = true;
public bool RequireLowercase { get; set; } = true;
public bool RequireDigit { get; set; } = true;
public bool RequireSymbol { get; set; } = true;
public void Validate(string pluginName)
{
if (MinimumLength <= 0)
{
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires passwordPolicy.minimumLength to be greater than zero.");
}
}
}
internal sealed class LockoutOptions
{
public bool Enabled { get; set; } = true;
public int MaxAttempts { get; set; } = 5;
public int WindowMinutes { get; set; } = 15;
public TimeSpan Window => TimeSpan.FromMinutes(WindowMinutes <= 0 ? 15 : WindowMinutes);
public void Validate(string pluginName)
{
if (Enabled && MaxAttempts <= 0)
{
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires lockout.maxAttempts to be greater than zero when lockout is enabled.");
}
if (Enabled && WindowMinutes <= 0)
{
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires lockout.windowMinutes to be greater than zero when lockout is enabled.");
}
}
}
internal sealed class TokenSigningOptions
{
public string? KeyDirectory { get; set; }
public void Normalize(string configPath)
{
if (string.IsNullOrWhiteSpace(KeyDirectory))
{
KeyDirectory = null;
return;
}
var resolved = KeyDirectory.Trim();
if (string.IsNullOrEmpty(resolved))
{
KeyDirectory = null;
return;
}
resolved = Environment.ExpandEnvironmentVariables(resolved);
if (!Path.IsPathRooted(resolved))
{
var baseDirectory = Path.GetDirectoryName(configPath);
if (string.IsNullOrEmpty(baseDirectory))
{
baseDirectory = Directory.GetCurrentDirectory();
}
resolved = Path.Combine(baseDirectory, resolved);
}
KeyDirectory = Path.GetFullPath(resolved);
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
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;
namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
{
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.TryAddSingleton<IPasswordHasher, Pbkdf2PasswordHasher>();
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
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.AddSingleton(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddSingleton(sp =>
{
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
return new StandardClientProvisioningStore(pluginName, clientStore);
});
context.Services.AddSingleton<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.AddSingleton<IClientProvisioningStore>(sp =>
sp.GetRequiredService<StandardClientProvisioningStore>());
context.Services.AddSingleton<IHostedService>(sp =>
new StandardPluginBootstrapper(
pluginName,
sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>(),
sp.GetRequiredService<StandardUserCredentialStore>(),
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,109 @@
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;
public StandardClientProvisioningStore(string pluginName, IAuthorityClientStore clientStore)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
}
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 = DateTimeOffset.UtcNow };
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.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = string.Join(" ", registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = string.Join(" ", registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
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);
return deleted
? AuthorityPluginOperationResult.Success()
: AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}

View File

@@ -0,0 +1,329 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserCredentialStore : IUserCredentialStore
{
private readonly IMongoCollection<StandardUserDocument> users;
private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher;
private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName;
public StandardUserCredentialStore(
string pluginName,
IMongoDatabase database,
StandardPluginOptions options,
IPasswordHasher passwordHasher,
ILogger<StandardUserCredentialStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(database);
var collectionName = $"authority_users_{pluginName.ToLowerInvariant()}";
users = database.GetCollection<StandardUserDocument>(collectionName);
EnsureIndexes();
}
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
string username,
string password,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
{
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
}
var normalized = NormalizeUsername(username);
var user = await users.Find(u => u.NormalizedUsername == normalized)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (user is null)
{
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
}
if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
{
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.LockedOut,
"Account is temporarily locked.",
retryAfter);
}
var verification = passwordHasher.Verify(password, user.PasswordHash);
if (verification is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded)
{
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = passwordHasher.Hash(password);
}
ResetLockout(user);
user.UpdatedAt = DateTimeOffset.UtcNow;
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
var descriptor = ToDescriptor(user);
return AuthorityCredentialVerificationResult.Success(descriptor, descriptor.RequiresPasswordReset ? "Password reset required." : null);
}
await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
var code = options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockout
? AuthorityCredentialFailureCode.LockedOut
: AuthorityCredentialFailureCode.InvalidCredentials;
TimeSpan? retry = user.Lockout.LockoutEnd is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
? lockoutTime - DateTimeOffset.UtcNow
: null;
return AuthorityCredentialVerificationResult.Failure(
code,
code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
retry);
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
AuthorityUserRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
var normalized = NormalizeUsername(registration.Username);
var now = DateTimeOffset.UtcNow;
if (!string.IsNullOrEmpty(registration.Password))
{
var passwordValidation = ValidatePassword(registration.Password);
if (passwordValidation is not null)
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_policy_violation", passwordValidation);
}
}
var existing = await users.Find(u => u.NormalizedUsername == normalized)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
if (string.IsNullOrEmpty(registration.Password))
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_required", "New users require a password.");
}
var document = new StandardUserDocument
{
Username = registration.Username,
NormalizedUsername = normalized,
DisplayName = registration.DisplayName,
Email = registration.Email,
PasswordHash = passwordHasher.Hash(registration.Password!),
RequirePasswordReset = registration.RequirePasswordReset,
Roles = registration.Roles.ToList(),
Attributes = new Dictionary<string, string?>(registration.Attributes, StringComparer.OrdinalIgnoreCase),
CreatedAt = now,
UpdatedAt = now
};
await users.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(document));
}
existing.Username = registration.Username;
existing.DisplayName = registration.DisplayName ?? existing.DisplayName;
existing.Email = registration.Email ?? existing.Email;
existing.Roles = registration.Roles.Any()
? registration.Roles.ToList()
: existing.Roles;
if (registration.Attributes.Count > 0)
{
foreach (var pair in registration.Attributes)
{
existing.Attributes[pair.Key] = pair.Value;
}
}
if (!string.IsNullOrEmpty(registration.Password))
{
existing.PasswordHash = passwordHasher.Hash(registration.Password!);
existing.RequirePasswordReset = registration.RequirePasswordReset;
}
else if (registration.RequirePasswordReset)
{
existing.RequirePasswordReset = true;
}
existing.UpdatedAt = now;
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, existing.Id),
existing,
cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(existing));
}
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return null;
}
var user = await users.Find(u => u.SubjectId == subjectId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return user is null ? null : ToDescriptor(user);
}
public async Task EnsureBootstrapUserAsync(BootstrapUserOptions bootstrap, CancellationToken cancellationToken)
{
if (bootstrap is null || !bootstrap.IsConfigured)
{
return;
}
var registration = new AuthorityUserRegistration(
bootstrap.Username!,
bootstrap.Password,
displayName: bootstrap.Username,
email: null,
requirePasswordReset: bootstrap.RequirePasswordReset,
roles: Array.Empty<string>(),
attributes: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
var result = await UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
if (!result.Succeeded)
{
logger.LogWarning(
"Plugin {PluginName} failed to seed bootstrap user '{Username}': {Reason}",
pluginName,
bootstrap.Username,
result.ErrorCode);
}
}
public async Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
{
try
{
var command = new BsonDocument("ping", 1);
await users.Database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginHealthResult.Healthy();
}
catch (Exception ex)
{
logger.LogError(ex, "Plugin {PluginName} failed MongoDB health check.", pluginName);
return AuthorityPluginHealthResult.Unavailable(ex.Message);
}
}
private string? ValidatePassword(string password)
{
if (password.Length < options.PasswordPolicy.MinimumLength)
{
return $"Password must be at least {options.PasswordPolicy.MinimumLength} characters long.";
}
if (options.PasswordPolicy.RequireUppercase && !password.Any(char.IsUpper))
{
return "Password must contain an uppercase letter.";
}
if (options.PasswordPolicy.RequireLowercase && !password.Any(char.IsLower))
{
return "Password must contain a lowercase letter.";
}
if (options.PasswordPolicy.RequireDigit && !password.Any(char.IsDigit))
{
return "Password must contain a digit.";
}
if (options.PasswordPolicy.RequireSymbol && password.All(char.IsLetterOrDigit))
{
return "Password must contain a symbol.";
}
return null;
}
private async Task RegisterFailureAsync(StandardUserDocument user, CancellationToken cancellationToken)
{
user.Lockout.LastFailure = DateTimeOffset.UtcNow;
user.Lockout.FailedAttempts += 1;
if (options.Lockout.Enabled && user.Lockout.FailedAttempts >= options.Lockout.MaxAttempts)
{
user.Lockout.LockoutEnd = DateTimeOffset.UtcNow + options.Lockout.Window;
user.Lockout.FailedAttempts = 0;
}
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static void ResetLockout(StandardUserDocument user)
{
user.Lockout.FailedAttempts = 0;
user.Lockout.LockoutEnd = null;
user.Lockout.LastFailure = null;
}
private static string NormalizeUsername(string username)
=> username.Trim().ToLowerInvariant();
private AuthorityUserDescriptor ToDescriptor(StandardUserDocument document)
=> new(
document.SubjectId,
document.Username,
document.DisplayName,
document.RequirePasswordReset,
document.Roles,
document.Attributes);
private void EnsureIndexes()
{
var indexKeys = Builders<StandardUserDocument>.IndexKeys
.Ascending(u => u.NormalizedUsername);
var indexModel = new CreateIndexModel<StandardUserDocument>(
indexKeys,
new CreateIndexOptions { Unique = true, Name = "idx_normalized_username" });
try
{
users.Indexes.CreateOne(indexModel);
}
catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
}
}
}

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserDocument
{
[BsonId]
public ObjectId Id { get; set; }
[BsonElement("subjectId")]
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("username")]
public string Username { get; set; } = string.Empty;
[BsonElement("normalizedUsername")]
public string NormalizedUsername { get; set; } = string.Empty;
[BsonElement("passwordHash")]
public string PasswordHash { get; set; } = string.Empty;
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("email")]
[BsonIgnoreIfNull]
public string? Email { get; set; }
[BsonElement("requirePasswordReset")]
public bool RequirePasswordReset { get; set; }
[BsonElement("roles")]
public List<string> Roles { get; set; } = new();
[BsonElement("attributes")]
public Dictionary<string, string?> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("lockout")]
public StandardLockoutState Lockout { get; set; } = new();
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
internal sealed class StandardLockoutState
{
[BsonElement("failedAttempts")]
public int FailedAttempts { get; set; }
[BsonElement("lockoutEnd")]
[BsonIgnoreIfNull]
public DateTimeOffset? LockoutEnd { get; set; }
[BsonElement("lastFailure")]
[BsonIgnoreIfNull]
public DateTimeOffset? LastFailure { get; set; }
}

View File

@@ -0,0 +1,16 @@
# Team 8 / Plugin Standard Backlog (UTC 2025-10-10)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1PLG5 | Final polish + diagrams for plugin developer guide. | Docs team delivers copy-edit + exported diagrams; PR merged. |
| SEC1.PLG | TODO | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
| SEC1.OPT | TODO | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
| SEC2.PLG | TODO | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | TODO | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC4.PLG | TODO | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
| SEC5.PLG | TODO | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| PLG4-6.CAPABILITIES | DOING (2025-10-10) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.

View File

@@ -0,0 +1,31 @@
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);
var updated = registration.WithClientSecret("secret");
Assert.Equal("cli", updated.ClientId);
Assert.Equal("secret", updated.ClientSecret);
Assert.False(updated.Confidential);
}
}

View File

@@ -0,0 +1,38 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityCredentialVerificationResultTests
{
[Fact]
public void Success_SetsUserAndClearsFailure()
{
var user = new AuthorityUserDescriptor("subject-1", "user", "User", false);
var result = AuthorityCredentialVerificationResult.Success(user, "ok");
Assert.True(result.Succeeded);
Assert.Equal(user, result.User);
Assert.Null(result.FailureCode);
Assert.Equal("ok", result.Message);
}
[Fact]
public void Success_Throws_WhenUserNull()
{
Assert.Throws<ArgumentNullException>(() => AuthorityCredentialVerificationResult.Success(null!));
}
[Fact]
public void Failure_SetsFailureCode()
{
var result = AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.LockedOut, "locked", TimeSpan.FromMinutes(5));
Assert.False(result.Succeeded);
Assert.Null(result.User);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, result.FailureCode);
Assert.Equal("locked", result.Message);
Assert.Equal(TimeSpan.FromMinutes(5), result.RetryAfter);
}
}

View File

@@ -0,0 +1,42 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityIdentityProviderCapabilitiesTests
{
[Fact]
public void FromCapabilities_SetsFlags_WhenTokensPresent()
{
var capabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(new[]
{
"password",
"mfa",
"clientProvisioning"
});
Assert.True(capabilities.SupportsPassword);
Assert.True(capabilities.SupportsMfa);
Assert.True(capabilities.SupportsClientProvisioning);
}
[Fact]
public void FromCapabilities_DefaultsToFalse_WhenEmpty()
{
var capabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(Array.Empty<string>());
Assert.False(capabilities.SupportsPassword);
Assert.False(capabilities.SupportsMfa);
Assert.False(capabilities.SupportsClientProvisioning);
}
[Fact]
public void FromCapabilities_IgnoresNullSet()
{
var capabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(null!);
Assert.False(capabilities.SupportsPassword);
Assert.False(capabilities.SupportsMfa);
Assert.False(capabilities.SupportsClientProvisioning);
}
}

View File

@@ -0,0 +1,32 @@
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityPluginHealthResultTests
{
[Fact]
public void Healthy_ReturnsHealthyStatus()
{
var result = AuthorityPluginHealthResult.Healthy("ready");
Assert.Equal(AuthorityPluginHealthStatus.Healthy, result.Status);
Assert.Equal("ready", result.Message);
Assert.NotNull(result.Details);
}
[Fact]
public void Degraded_ReturnsDegradedStatus()
{
var result = AuthorityPluginHealthResult.Degraded("slow");
Assert.Equal(AuthorityPluginHealthStatus.Degraded, result.Status);
}
[Fact]
public void Unavailable_ReturnsUnavailableStatus()
{
var result = AuthorityPluginHealthResult.Unavailable("down");
Assert.Equal(AuthorityPluginHealthStatus.Unavailable, result.Status);
}
}

View File

@@ -0,0 +1,60 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityPluginOperationResultTests
{
[Fact]
public void Success_ReturnsSucceededResult()
{
var result = AuthorityPluginOperationResult.Success("ok");
Assert.True(result.Succeeded);
Assert.Null(result.ErrorCode);
Assert.Equal("ok", result.Message);
}
[Fact]
public void Failure_PopulatesErrorCode()
{
var result = AuthorityPluginOperationResult.Failure("ERR_CODE", "failure");
Assert.False(result.Succeeded);
Assert.Equal("ERR_CODE", result.ErrorCode);
Assert.Equal("failure", result.Message);
}
[Fact]
public void Failure_Throws_WhenErrorCodeMissing()
{
Assert.Throws<ArgumentException>(() => AuthorityPluginOperationResult.Failure(string.Empty));
}
[Fact]
public void GenericSuccess_ReturnsValue()
{
var result = AuthorityPluginOperationResult<string>.Success("value", "created");
Assert.True(result.Succeeded);
Assert.Equal("value", result.Value);
Assert.Equal("created", result.Message);
}
[Fact]
public void GenericFailure_PopulatesErrorCode()
{
var result = AuthorityPluginOperationResult<int>.Failure("CONFLICT", "duplicate");
Assert.False(result.Succeeded);
Assert.Equal(default, result.Value);
Assert.Equal("CONFLICT", result.ErrorCode);
Assert.Equal("duplicate", result.Message);
}
[Fact]
public void GenericFailure_Throws_WhenErrorCodeMissing()
{
Assert.Throws<ArgumentException>(() => AuthorityPluginOperationResult<string>.Failure(" "));
}
}

View File

@@ -0,0 +1,28 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityUserDescriptorTests
{
[Fact]
public void Constructor_Throws_WhenSubjectMissing()
{
Assert.Throws<ArgumentException>(() => new AuthorityUserDescriptor(string.Empty, "user", null, false));
}
[Fact]
public void Constructor_Throws_WhenUsernameMissing()
{
Assert.Throws<ArgumentException>(() => new AuthorityUserDescriptor("subject", " ", null, false));
}
[Fact]
public void Constructor_MaterialisesCollections()
{
var descriptor = new AuthorityUserDescriptor("subject", "user", null, false);
Assert.NotNull(descriptor.Roles);
Assert.NotNull(descriptor.Attributes);
}
}

View File

@@ -0,0 +1,25 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityUserRegistrationTests
{
[Fact]
public void Constructor_Throws_WhenUsernameMissing()
{
Assert.Throws<ArgumentException>(() => new AuthorityUserRegistration(string.Empty, null, null, null, false));
}
[Fact]
public void WithPassword_ReturnsCopyWithPassword()
{
var registration = new AuthorityUserRegistration("alice", null, "Alice", null, true);
var updated = registration.WithPassword("secret");
Assert.Equal("alice", updated.Username);
Assert.Equal("secret", updated.Password);
Assert.True(updated.RequirePasswordReset);
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known metadata keys persisted with Authority client registrations.
/// </summary>
public static class AuthorityClientMetadataKeys
{
public const string AllowedGrantTypes = "allowedGrantTypes";
public const string AllowedScopes = "allowedScopes";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Configuration;
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 all registered identity provider plugins keyed by logical name.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> Providers { get; }
/// <summary>
/// Gets identity providers that advertise password support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> PasswordProviders { get; }
/// <summary>
/// Gets identity providers that advertise multi-factor authentication support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> MfaProviders { get; }
/// <summary>
/// Gets identity providers that advertise client provisioning support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> ClientProvisioningProviders { get; }
/// <summary>
/// Aggregate capability flags across all registered providers.
/// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary>
/// Attempts to resolve an identity provider by name.
/// </summary>
bool TryGet(string name, [NotNullWhen(true)] out IIdentityProviderPlugin? provider);
/// <summary>
/// Resolves an identity provider by name or throws when not found.
/// </summary>
IIdentityProviderPlugin GetRequired(string name)
{
if (TryGet(name, out var provider))
{
return provider;
}
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
}

View File

@@ -0,0 +1,60 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Provides shared services and metadata to Authority plugin registrars during DI setup.
/// </summary>
public sealed class AuthorityPluginRegistrationContext
{
/// <summary>
/// Initialises a new registration context.
/// </summary>
/// <param name="services">Service collection used to register plugin services.</param>
/// <param name="plugin">Plugin context describing the manifest and configuration.</param>
/// <param name="hostConfiguration">Root host configuration available during registration.</param>
/// <exception cref="ArgumentNullException">Thrown when any argument is null.</exception>
public AuthorityPluginRegistrationContext(
IServiceCollection services,
AuthorityPluginContext plugin,
IConfiguration hostConfiguration)
{
Services = services ?? throw new ArgumentNullException(nameof(services));
Plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
HostConfiguration = hostConfiguration ?? throw new ArgumentNullException(nameof(hostConfiguration));
}
/// <summary>
/// Gets the service collection used to register plugin dependencies.
/// </summary>
public IServiceCollection Services { get; }
/// <summary>
/// Gets the plugin context containing manifest metadata and configuration.
/// </summary>
public AuthorityPluginContext Plugin { get; }
/// <summary>
/// Gets the root configuration associated with the Authority host.
/// </summary>
public IConfiguration HostConfiguration { get; }
}
/// <summary>
/// Registers Authority plugin services for a specific plugin type.
/// </summary>
public interface IAuthorityPluginRegistrar
{
/// <summary>
/// Logical plugin type identifier supported by this registrar (e.g. <c>standard</c>, <c>ldap</c>).
/// </summary>
string PluginType { get; }
/// <summary>
/// Registers services for the supplied plugin context.
/// </summary>
/// <param name="context">Registration context containing services and metadata.</param>
void Register(AuthorityPluginRegistrationContext context);
}

View File

@@ -0,0 +1,25 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Deterministic hashing utilities for secrets managed by Authority plugins.
/// </summary>
public static class AuthoritySecretHasher
{
/// <summary>
/// Computes a stable SHA-256 hash for the provided secret.
/// </summary>
public static string ComputeHash(string secret)
{
if (string.IsNullOrEmpty(secret))
{
return string.Empty;
}
using var sha256 = SHA256.Create();
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(secret));
return Convert.ToBase64String(bytes);
}
}

View File

@@ -0,0 +1,785 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Describes feature support advertised by an identity provider plugin.
/// </summary>
public sealed record AuthorityIdentityProviderCapabilities(
bool SupportsPassword,
bool SupportsMfa,
bool SupportsClientProvisioning)
{
/// <summary>
/// Builds capabilities metadata from a list of capability identifiers.
/// </summary>
public static AuthorityIdentityProviderCapabilities FromCapabilities(IEnumerable<string> capabilities)
{
if (capabilities is null)
{
return new AuthorityIdentityProviderCapabilities(false, false, false);
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in capabilities)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
seen.Add(entry.Trim());
}
return new AuthorityIdentityProviderCapabilities(
SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password),
SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa),
SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning));
}
}
/// <summary>
/// Represents a loaded Authority identity provider plugin instance.
/// </summary>
public interface IIdentityProviderPlugin
{
/// <summary>
/// Gets the logical name of the plugin instance (matches the manifest key).
/// </summary>
string Name { get; }
/// <summary>
/// Gets the plugin type identifier (e.g. <c>standard</c>, <c>ldap</c>).
/// </summary>
string Type { get; }
/// <summary>
/// Gets the plugin context comprising the manifest and bound configuration.
/// </summary>
AuthorityPluginContext Context { get; }
/// <summary>
/// Gets the credential store responsible for authenticator validation and user provisioning.
/// </summary>
IUserCredentialStore Credentials { get; }
/// <summary>
/// Gets the claims enricher applied to issued principals.
/// </summary>
IClaimsEnricher ClaimsEnricher { get; }
/// <summary>
/// Gets the optional client provisioning store exposed by the plugin.
/// </summary>
IClientProvisioningStore? ClientProvisioning { get; }
/// <summary>
/// Gets the capability metadata advertised by the plugin.
/// </summary>
AuthorityIdentityProviderCapabilities Capabilities { get; }
/// <summary>
/// Evaluates the health of the plugin and backing data stores.
/// </summary>
/// <param name="cancellationToken">Token used to cancel the operation.</param>
/// <returns>Health result describing the plugin status.</returns>
ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Supplies operations for validating credentials and managing user records.
/// </summary>
public interface IUserCredentialStore
{
/// <summary>
/// Verifies the supplied username/password combination.
/// </summary>
ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
string username,
string password,
CancellationToken cancellationToken);
/// <summary>
/// Creates or updates a user record based on the supplied registration data.
/// </summary>
ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
AuthorityUserRegistration registration,
CancellationToken cancellationToken);
/// <summary>
/// Attempts to resolve a user descriptor by its canonical subject identifier.
/// </summary>
ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(
string subjectId,
CancellationToken cancellationToken);
}
/// <summary>
/// Enriches issued principals with additional claims based on plugin-specific rules.
/// </summary>
public interface IClaimsEnricher
{
/// <summary>
/// Adds or adjusts claims on the provided identity.
/// </summary>
ValueTask EnrichAsync(
ClaimsIdentity identity,
AuthorityClaimsEnrichmentContext context,
CancellationToken cancellationToken);
}
/// <summary>
/// Manages client (machine-to-machine) provisioning for Authority.
/// </summary>
public interface IClientProvisioningStore
{
/// <summary>
/// Creates or updates a client registration.
/// </summary>
ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken);
/// <summary>
/// Attempts to resolve a client descriptor by its identifier.
/// </summary>
ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(
string clientId,
CancellationToken cancellationToken);
/// <summary>
/// Removes a client registration.
/// </summary>
ValueTask<AuthorityPluginOperationResult> DeleteAsync(
string clientId,
CancellationToken cancellationToken);
}
/// <summary>
/// Represents the health state of a plugin or backing store.
/// </summary>
public enum AuthorityPluginHealthStatus
{
/// <summary>
/// Plugin is healthy and operational.
/// </summary>
Healthy,
/// <summary>
/// Plugin is degraded but still usable (e.g. transient connectivity issues).
/// </summary>
Degraded,
/// <summary>
/// Plugin is unavailable and cannot service requests.
/// </summary>
Unavailable
}
/// <summary>
/// Result of a plugin health probe.
/// </summary>
public sealed record AuthorityPluginHealthResult
{
private AuthorityPluginHealthResult(
AuthorityPluginHealthStatus status,
string? message,
IReadOnlyDictionary<string, string?> details)
{
Status = status;
Message = message;
Details = details;
}
/// <summary>
/// Gets the overall status of the plugin.
/// </summary>
public AuthorityPluginHealthStatus Status { get; }
/// <summary>
/// Gets an optional human-readable status description.
/// </summary>
public string? Message { get; }
/// <summary>
/// Gets optional structured details for diagnostics.
/// </summary>
public IReadOnlyDictionary<string, string?> Details { get; }
/// <summary>
/// Creates a healthy result.
/// </summary>
public static AuthorityPluginHealthResult Healthy(
string? message = null,
IReadOnlyDictionary<string, string?>? details = null)
=> new(AuthorityPluginHealthStatus.Healthy, message, details ?? EmptyDetails);
/// <summary>
/// Creates a degraded result.
/// </summary>
public static AuthorityPluginHealthResult Degraded(
string? message = null,
IReadOnlyDictionary<string, string?>? details = null)
=> new(AuthorityPluginHealthStatus.Degraded, message, details ?? EmptyDetails);
/// <summary>
/// Creates an unavailable result.
/// </summary>
public static AuthorityPluginHealthResult Unavailable(
string? message = null,
IReadOnlyDictionary<string, string?>? details = null)
=> new(AuthorityPluginHealthStatus.Unavailable, message, details ?? EmptyDetails);
private static readonly IReadOnlyDictionary<string, string?> EmptyDetails =
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Describes a canonical Authority user surfaced by a plugin.
/// </summary>
public sealed record AuthorityUserDescriptor
{
/// <summary>
/// Initialises a new user descriptor.
/// </summary>
public AuthorityUserDescriptor(
string subjectId,
string username,
string? displayName,
bool requiresPasswordReset,
IReadOnlyCollection<string>? roles = null,
IReadOnlyDictionary<string, string?>? attributes = null)
{
SubjectId = ValidateRequired(subjectId, nameof(subjectId));
Username = ValidateRequired(username, nameof(username));
DisplayName = displayName;
RequiresPasswordReset = requiresPasswordReset;
Roles = roles is null ? Array.Empty<string>() : roles.ToArray();
Attributes = attributes is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(attributes, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Stable subject identifier for token issuance.
/// </summary>
public string SubjectId { get; }
/// <summary>
/// Canonical username (case-normalised).
/// </summary>
public string Username { get; }
/// <summary>
/// Optional human-friendly display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Indicates whether the user must reset their password.
/// </summary>
public bool RequiresPasswordReset { get; }
/// <summary>
/// Collection of role identifiers associated with the user.
/// </summary>
public IReadOnlyCollection<string> Roles { get; }
/// <summary>
/// Arbitrary plugin-defined attributes (used by claims enricher).
/// </summary>
public IReadOnlyDictionary<string, string?> Attributes { get; }
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}
/// <summary>
/// Outcome of a credential verification attempt.
/// </summary>
public sealed record AuthorityCredentialVerificationResult
{
private AuthorityCredentialVerificationResult(
bool succeeded,
AuthorityUserDescriptor? user,
AuthorityCredentialFailureCode? failureCode,
string? message,
TimeSpan? retryAfter)
{
Succeeded = succeeded;
User = user;
FailureCode = failureCode;
Message = message;
RetryAfter = retryAfter;
}
/// <summary>
/// Indicates whether the verification succeeded.
/// </summary>
public bool Succeeded { get; }
/// <summary>
/// Resolved user descriptor when successful.
/// </summary>
public AuthorityUserDescriptor? User { get; }
/// <summary>
/// Failure classification when unsuccessful.
/// </summary>
public AuthorityCredentialFailureCode? FailureCode { get; }
/// <summary>
/// Optional message describing the outcome.
/// </summary>
public string? Message { get; }
/// <summary>
/// Optional suggested retry interval (e.g. for lockouts).
/// </summary>
public TimeSpan? RetryAfter { get; }
/// <summary>
/// Builds a successful verification result.
/// </summary>
public static AuthorityCredentialVerificationResult Success(
AuthorityUserDescriptor user,
string? message = null)
=> new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null);
/// <summary>
/// Builds a failed verification result.
/// </summary>
public static AuthorityCredentialVerificationResult Failure(
AuthorityCredentialFailureCode failureCode,
string? message = null,
TimeSpan? retryAfter = null)
=> new(false, null, failureCode, message, retryAfter);
}
/// <summary>
/// Classifies credential verification failures.
/// </summary>
public enum AuthorityCredentialFailureCode
{
/// <summary>
/// Username/password combination is invalid.
/// </summary>
InvalidCredentials,
/// <summary>
/// Account is locked out (retry after a specified duration).
/// </summary>
LockedOut,
/// <summary>
/// Password has expired and must be reset.
/// </summary>
PasswordExpired,
/// <summary>
/// User must reset password before proceeding.
/// </summary>
RequiresPasswordReset,
/// <summary>
/// Additional multi-factor authentication is required.
/// </summary>
RequiresMfa,
/// <summary>
/// Unexpected failure occurred (see message for details).
/// </summary>
UnknownError
}
/// <summary>
/// Represents a user provisioning request.
/// </summary>
public sealed record AuthorityUserRegistration
{
/// <summary>
/// Initialises a new registration.
/// </summary>
public AuthorityUserRegistration(
string username,
string? password,
string? displayName,
string? email,
bool requirePasswordReset,
IReadOnlyCollection<string>? roles = null,
IReadOnlyDictionary<string, string?>? attributes = null)
{
Username = ValidateRequired(username, nameof(username));
Password = password;
DisplayName = displayName;
Email = email;
RequirePasswordReset = requirePasswordReset;
Roles = roles is null ? Array.Empty<string>() : roles.ToArray();
Attributes = attributes is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(attributes, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Canonical username (unique).
/// </summary>
public string Username { get; }
/// <summary>
/// Optional raw password (hashed by plugin).
/// </summary>
public string? Password { get; init; }
/// <summary>
/// Optional human-friendly display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Optional contact email.
/// </summary>
public string? Email { get; }
/// <summary>
/// Indicates whether the user must reset their password at next login.
/// </summary>
public bool RequirePasswordReset { get; }
/// <summary>
/// Associated roles.
/// </summary>
public IReadOnlyCollection<string> Roles { get; }
/// <summary>
/// Plugin-defined attributes.
/// </summary>
public IReadOnlyDictionary<string, string?> Attributes { get; }
/// <summary>
/// Creates a copy with the provided password while preserving other fields.
/// </summary>
public AuthorityUserRegistration WithPassword(string? password)
=> new(Username, password, DisplayName, Email, RequirePasswordReset, Roles, Attributes);
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}
/// <summary>
/// Generic operation result utilised by plugins.
/// </summary>
public sealed record AuthorityPluginOperationResult
{
private AuthorityPluginOperationResult(bool succeeded, string? errorCode, string? message)
{
Succeeded = succeeded;
ErrorCode = errorCode;
Message = message;
}
/// <summary>
/// Indicates whether the operation succeeded.
/// </summary>
public bool Succeeded { get; }
/// <summary>
/// Machine-readable error code (populated on failure).
/// </summary>
public string? ErrorCode { get; }
/// <summary>
/// Optional human-readable message.
/// </summary>
public string? Message { get; }
/// <summary>
/// Returns a successful result.
/// </summary>
public static AuthorityPluginOperationResult Success(string? message = null)
=> new(true, null, message);
/// <summary>
/// Returns a failed result with the supplied error code.
/// </summary>
public static AuthorityPluginOperationResult Failure(string errorCode, string? message = null)
=> new(false, ValidateErrorCode(errorCode), message);
internal static string ValidateErrorCode(string errorCode)
=> string.IsNullOrWhiteSpace(errorCode)
? throw new ArgumentException("Error code is required for failures.", nameof(errorCode))
: errorCode;
}
/// <summary>
/// Generic operation result that returns a value.
/// </summary>
public sealed record AuthorityPluginOperationResult<TValue>
{
private AuthorityPluginOperationResult(
bool succeeded,
TValue? value,
string? errorCode,
string? message)
{
Succeeded = succeeded;
Value = value;
ErrorCode = errorCode;
Message = message;
}
/// <summary>
/// Indicates whether the operation succeeded.
/// </summary>
public bool Succeeded { get; }
/// <summary>
/// Returned value when successful.
/// </summary>
public TValue? Value { get; }
/// <summary>
/// Machine-readable error code (on failure).
/// </summary>
public string? ErrorCode { get; }
/// <summary>
/// Optional human-readable message.
/// </summary>
public string? Message { get; }
/// <summary>
/// Returns a successful result with the provided value.
/// </summary>
public static AuthorityPluginOperationResult<TValue> Success(TValue value, string? message = null)
=> new(true, value, null, message);
/// <summary>
/// Returns a successful result without a value (defaults to <c>default</c>).
/// </summary>
public static AuthorityPluginOperationResult<TValue> Success(string? message = null)
=> new(true, default, null, message);
/// <summary>
/// Returns a failed result with the supplied error code.
/// </summary>
public static AuthorityPluginOperationResult<TValue> Failure(string errorCode, string? message = null)
=> new(false, default, AuthorityPluginOperationResult.ValidateErrorCode(errorCode), message);
}
/// <summary>
/// Context supplied to claims enrichment routines.
/// </summary>
public sealed class AuthorityClaimsEnrichmentContext
{
private readonly Dictionary<string, object?> items;
/// <summary>
/// Initialises a new context instance.
/// </summary>
public AuthorityClaimsEnrichmentContext(
AuthorityPluginContext plugin,
AuthorityUserDescriptor? user,
AuthorityClientDescriptor? client)
{
Plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
User = user;
Client = client;
items = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Gets the plugin context associated with the principal.
/// </summary>
public AuthorityPluginContext Plugin { get; }
/// <summary>
/// Gets the user descriptor when available.
/// </summary>
public AuthorityUserDescriptor? User { get; }
/// <summary>
/// Gets the client descriptor when available.
/// </summary>
public AuthorityClientDescriptor? Client { get; }
/// <summary>
/// Extensible bag for plugin-specific data passed between enrichment stages.
/// </summary>
public IDictionary<string, object?> Items => items;
}
/// <summary>
/// Represents a registered OAuth/OpenID client.
/// </summary>
public sealed record AuthorityClientDescriptor
{
/// <summary>
/// Initialises a new client descriptor.
/// </summary>
public AuthorityClientDescriptor(
string clientId,
string? displayName,
bool confidential,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
DisplayName = displayName;
Confidential = confidential;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Permitted OAuth grant types.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Permitted scopes.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Registered redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Registered post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional plugin-defined metadata.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}
/// <summary>
/// Client registration payload used when provisioning clients through plugins.
/// </summary>
public sealed record AuthorityClientRegistration
{
/// <summary>
/// Initialises a new registration.
/// </summary>
public AuthorityClientRegistration(
string clientId,
bool confidential,
string? displayName,
string? clientSecret,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
Confidential = confidential;
DisplayName = displayName;
ClientSecret = confidential
? ValidateRequired(clientSecret ?? string.Empty, nameof(clientSecret))
: clientSecret;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret handling).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Optional raw client secret (hashed by the plugin for storage).
/// </summary>
public string? ClientSecret { get; init; }
/// <summary>
/// Grant types to enable.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Scopes assigned to the client.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Redirect URIs permitted for the client.
/// </summary>
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional metadata for the plugin.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
/// <summary>
/// Creates a copy of the registration with the provided client secret.
/// </summary>
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, RedirectUris, PostLogoutRedirectUris, Properties);
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Authority.Storage.Mongo;
/// <summary>
/// Constants describing default collection names and other MongoDB defaults for the Authority service.
/// </summary>
public static class AuthorityMongoDefaults
{
/// <summary>
/// Default database name used when none is provided via configuration.
/// </summary>
public const string DefaultDatabaseName = "stellaops_authority";
/// <summary>
/// Canonical collection names used by Authority.
/// </summary>
public static class Collections
{
public const string Users = "authority_users";
public const string Clients = "authority_clients";
public const string Scopes = "authority_scopes";
public const string Tokens = "authority_tokens";
public const string LoginAttempts = "authority_login_attempts";
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Authority.Storage.Mongo;
public class Class1
{
}

View File

@@ -0,0 +1,61 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an OAuth client/application registered with Authority.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityClientDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("clientId")]
public string ClientId { get; set; } = string.Empty;
[BsonElement("clientType")]
public string ClientType { get; set; } = "confidential";
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("permissions")]
public List<string> Permissions { get; set; } = new();
[BsonElement("requirements")]
public List<string> Requirements { get; set; } = new();
[BsonElement("redirectUris")]
public List<string> RedirectUris { get; set; } = new();
[BsonElement("postLogoutRedirectUris")]
public List<string> PostLogoutRedirectUris { get; set; } = new();
[BsonElement("properties")]
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("plugin")]
[BsonIgnoreIfNull]
public string? Plugin { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("disabled")]
public bool Disabled { get; set; }
}

View File

@@ -0,0 +1,45 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents a recorded login attempt for audit and lockout purposes.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityLoginAttemptDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("subjectId")]
[BsonIgnoreIfNull]
public string? SubjectId { get; set; }
[BsonElement("username")]
[BsonIgnoreIfNull]
public string? Username { get; set; }
[BsonElement("clientId")]
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("plugin")]
[BsonIgnoreIfNull]
public string? Plugin { get; set; }
[BsonElement("successful")]
public bool Successful { get; set; }
[BsonElement("reason")]
[BsonIgnoreIfNull]
public string? Reason { get; set; }
[BsonElement("remoteAddress")]
[BsonIgnoreIfNull]
public string? RemoteAddress { get; set; }
[BsonElement("occurredAt")]
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,38 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an OAuth scope exposed by Authority.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityScopeDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("description")]
[BsonIgnoreIfNull]
public string? Description { get; set; }
[BsonElement("resources")]
public List<string> Resources { get; set; } = new();
[BsonElement("properties")]
public Dictionary<string, string?> Properties { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,54 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents an OAuth token issued by Authority.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityTokenDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("tokenId")]
public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("type")]
public string Type { get; set; } = string.Empty;
[BsonElement("subjectId")]
[BsonIgnoreIfNull]
public string? SubjectId { get; set; }
[BsonElement("clientId")]
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("scope")]
public List<string> Scope { get; set; } = new();
[BsonElement("referenceId")]
[BsonIgnoreIfNull]
public string? ReferenceId { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "valid";
[BsonElement("payload")]
[BsonIgnoreIfNull]
public string? Payload { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
[BsonElement("revokedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? RevokedAt { get; set; }
}

View File

@@ -0,0 +1,51 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents a canonical Authority user persisted in MongoDB.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityUserDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("subjectId")]
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("username")]
public string Username { get; set; } = string.Empty;
[BsonElement("normalizedUsername")]
public string NormalizedUsername { get; set; } = string.Empty;
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("email")]
[BsonIgnoreIfNull]
public string? Email { get; set; }
[BsonElement("disabled")]
public bool Disabled { get; set; }
[BsonElement("roles")]
public List<string> Roles { get; set; } = new();
[BsonElement("attributes")]
public Dictionary<string, string?> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("plugin")]
[BsonIgnoreIfNull]
public string? Plugin { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,103 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Migrations;
using StellaOps.Authority.Storage.Mongo.Options;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Storage.Mongo.Extensions;
/// <summary>
/// Dependency injection helpers for wiring the Authority MongoDB storage layer.
/// </summary>
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAuthorityMongoStorage(
this IServiceCollection services,
Action<AuthorityMongoOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.AddOptions<AuthorityMongoOptions>()
.Configure(configureOptions)
.PostConfigure(static options => options.EnsureValid());
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IMongoClient>(static sp =>
{
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
return new MongoClient(options.ConnectionString);
});
services.AddSingleton<IMongoDatabase>(static sp =>
{
var options = sp.GetRequiredService<IOptions<AuthorityMongoOptions>>().Value;
var client = sp.GetRequiredService<IMongoClient>();
var settings = new MongoDatabaseSettings
{
ReadConcern = ReadConcern.Majority,
WriteConcern = WriteConcern.WMajority,
ReadPreference = ReadPreference.PrimaryPreferred
};
var database = client.GetDatabase(options.GetDatabaseName(), settings);
var writeConcern = database.Settings.WriteConcern.With(wTimeout: options.CommandTimeout);
return database.WithWriteConcern(writeConcern);
});
services.AddSingleton<AuthorityMongoInitializer>();
services.AddSingleton<AuthorityMongoMigrationRunner>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
});
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
var indexModels = new[]
{
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.ClientId),
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
new CreateIndexOptions { Name = "client_disabled" })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,26 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
var indexModels = new[]
{
new CreateIndexModel<AuthorityLoginAttemptDocument>(
Builders<AuthorityLoginAttemptDocument>.IndexKeys
.Ascending(a => a.SubjectId)
.Descending(a => a.OccurredAt),
new CreateIndexOptions { Name = "login_attempt_subject_time" }),
new CreateIndexModel<AuthorityLoginAttemptDocument>(
Builders<AuthorityLoginAttemptDocument>.IndexKeys.Descending(a => a.OccurredAt),
new CreateIndexOptions { Name = "login_attempt_time" })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Migrations;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
/// <summary>
/// Performs MongoDB bootstrap tasks for the Authority service.
/// </summary>
public sealed class AuthorityMongoInitializer
{
private readonly IEnumerable<IAuthorityCollectionInitializer> collectionInitializers;
private readonly AuthorityMongoMigrationRunner migrationRunner;
private readonly ILogger<AuthorityMongoInitializer> logger;
public AuthorityMongoInitializer(
IEnumerable<IAuthorityCollectionInitializer> collectionInitializers,
AuthorityMongoMigrationRunner migrationRunner,
ILogger<AuthorityMongoInitializer> logger)
{
this.collectionInitializers = collectionInitializers ?? throw new ArgumentNullException(nameof(collectionInitializers));
this.migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Ensures collections exist, migrations run, and indexes are applied.
/// </summary>
public async ValueTask InitialiseAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
await migrationRunner.RunAsync(database, cancellationToken).ConfigureAwait(false);
foreach (var initializer in collectionInitializers)
{
try
{
logger.LogInformation(
"Ensuring Authority Mongo indexes via {InitializerType}.",
initializer.GetType().FullName);
await initializer.EnsureIndexesAsync(database, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(
ex,
"Authority Mongo index initialisation failed for {InitializerType}.",
initializer.GetType().FullName);
throw;
}
}
}
}

View File

@@ -0,0 +1,21 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityScopeCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityScopeDocument>(AuthorityMongoDefaults.Collections.Scopes);
var indexModels = new[]
{
new CreateIndexModel<AuthorityScopeDocument>(
Builders<AuthorityScopeDocument>.IndexKeys.Ascending(s => s.Name),
new CreateIndexOptions { Name = "scope_name_unique", Unique = true })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,40 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
var indexModels = new List<CreateIndexModel<AuthorityTokenDocument>>
{
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.TokenId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_id_unique", Unique = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ReferenceId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_reference_unique", Unique = true, Sparse = true }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.SubjectId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
new(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" })
};
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
new CreateIndexOptions<AuthorityTokenDocument>
{
Name = "token_expiry_ttl",
ExpireAfter = TimeSpan.Zero,
PartialFilterExpression = expirationFilter
}));
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,27 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityUserCollectionInitializer : IAuthorityCollectionInitializer
{
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<AuthorityUserDocument>(AuthorityMongoDefaults.Collections.Users);
var indexModels = new[]
{
new CreateIndexModel<AuthorityUserDocument>(
Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.SubjectId),
new CreateIndexOptions { Name = "user_subject_unique", Unique = true }),
new CreateIndexModel<AuthorityUserDocument>(
Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.NormalizedUsername),
new CreateIndexOptions { Name = "user_normalized_username_unique", Unique = true, Sparse = true }),
new CreateIndexModel<AuthorityUserDocument>(
Builders<AuthorityUserDocument>.IndexKeys.Ascending(u => u.Email),
new CreateIndexOptions { Name = "user_email", Sparse = true })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,14 @@
using MongoDB.Driver;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
/// <summary>
/// Persists indexes and configuration for an Authority Mongo collection.
/// </summary>
public interface IAuthorityCollectionInitializer
{
/// <summary>
/// Ensures the collection's indexes exist.
/// </summary>
ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
namespace StellaOps.Authority.Storage.Mongo.Migrations;
/// <summary>
/// Executes registered Authority Mongo migrations sequentially.
/// </summary>
public sealed class AuthorityMongoMigrationRunner
{
private readonly IEnumerable<IAuthorityMongoMigration> migrations;
private readonly ILogger<AuthorityMongoMigrationRunner> logger;
public AuthorityMongoMigrationRunner(
IEnumerable<IAuthorityMongoMigration> migrations,
ILogger<AuthorityMongoMigrationRunner> logger)
{
this.migrations = migrations ?? throw new ArgumentNullException(nameof(migrations));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask RunAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
foreach (var migration in migrations)
{
try
{
logger.LogInformation("Running Authority Mongo migration {MigrationType}.", migration.GetType().FullName);
await migration.ExecuteAsync(database, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Authority Mongo migration {MigrationType} failed.", migration.GetType().FullName);
throw;
}
}
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Authority.Storage.Mongo.Migrations;
/// <summary>
/// Ensures base Authority collections exist prior to applying indexes.
/// </summary>
internal sealed class EnsureAuthorityCollectionsMigration : IAuthorityMongoMigration
{
private static readonly string[] RequiredCollections =
{
AuthorityMongoDefaults.Collections.Users,
AuthorityMongoDefaults.Collections.Clients,
AuthorityMongoDefaults.Collections.Scopes,
AuthorityMongoDefaults.Collections.Tokens,
AuthorityMongoDefaults.Collections.LoginAttempts
};
private readonly ILogger<EnsureAuthorityCollectionsMigration> logger;
public EnsureAuthorityCollectionsMigration(ILogger<EnsureAuthorityCollectionsMigration> logger)
=> this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var existing = await database.ListCollectionNamesAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var existingNames = await existing.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var collection in RequiredCollections)
{
if (existingNames.Contains(collection, StringComparer.OrdinalIgnoreCase))
{
continue;
}
logger.LogInformation("Creating Authority Mongo collection '{CollectionName}'.", collection);
await database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,16 @@
using MongoDB.Driver;
namespace StellaOps.Authority.Storage.Mongo.Migrations;
/// <summary>
/// Represents a Mongo migration run during Authority bootstrap.
/// </summary>
public interface IAuthorityMongoMigration
{
/// <summary>
/// Executes the migration.
/// </summary>
/// <param name="database">Mongo database instance.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken);
}

Some files were not shown because too many files have changed in this diff Show More