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
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:
143
src/DEDUP_CONFLICTS_RESOLUTION_ALGO.md
Normal file
143
src/DEDUP_CONFLICTS_RESOLUTION_ALGO.md
Normal 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
33
src/Directory.Build.props
Normal 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>
|
||||
33
src/Directory.Build.targets
Normal file
33
src/Directory.Build.targets
Normal 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>
|
||||
139
src/FASTER_MODELING_AND_NORMALIZATION.md
Normal file
139
src/FASTER_MODELING_AND_NORMALIZATION.md
Normal file
@@ -0,0 +1,139 @@
|
||||
Here’s a quick, practical idea to make your version-range modeling cleaner and faster to query.
|
||||
|
||||

|
||||
|
||||
# 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).
|
||||
20
src/StellaOps.Authority/AGENTS.md
Normal file
20
src/StellaOps.Authority/AGENTS.md
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)!;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/StellaOps.Authority/StellaOps.Auth.Client/FileTokenCache.cs
Normal file
122
src/StellaOps.Authority/StellaOps.Auth.Client/FileTokenCache.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Standard.Tests")]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>()));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 | PLG1–PLG5 | 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 | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
|
||||
| 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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Authority.Storage.Mongo;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user