up
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				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
				
			
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Build Test Deploy / build-test (push) Has been cancelled
				
			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
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		| @@ -1,29 +1,33 @@ | ||||
| <Project> | ||||
| <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> | ||||
|     </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> | ||||
|   | ||||
| @@ -1,17 +1,33 @@ | ||||
| <Project> | ||||
| <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> | ||||
|     <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> | ||||
| </Project> | ||||
|  | ||||
|   <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> | ||||
|   | ||||
| @@ -1,293 +1,293 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Feedser.Source.Common; | ||||
| using StellaOps.Feedser.Source.Common.Fetch; | ||||
| using StellaOps.Feedser.Source.Vndr.Oracle.Configuration; | ||||
| using StellaOps.Feedser.Source.Vndr.Oracle.Internal; | ||||
| using StellaOps.Feedser.Storage.Mongo; | ||||
| using StellaOps.Feedser.Storage.Mongo.Advisories; | ||||
| using StellaOps.Feedser.Storage.Mongo.Documents; | ||||
| using StellaOps.Feedser.Storage.Mongo.Dtos; | ||||
| using StellaOps.Feedser.Storage.Mongo.PsirtFlags; | ||||
| using StellaOps.Plugin; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.Vndr.Oracle; | ||||
|  | ||||
| public sealed class OracleConnector : IFeedConnector | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new() | ||||
|     { | ||||
|         PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||
|         DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, | ||||
|     }; | ||||
|  | ||||
|     private readonly SourceFetchService _fetchService; | ||||
|     private readonly RawDocumentStorage _rawDocumentStorage; | ||||
|     private readonly IDocumentStore _documentStore; | ||||
|     private readonly IDtoStore _dtoStore; | ||||
|     private readonly IAdvisoryStore _advisoryStore; | ||||
|     private readonly IPsirtFlagStore _psirtFlagStore; | ||||
|     private readonly ISourceStateRepository _stateRepository; | ||||
|     private readonly OracleOptions _options; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly ILogger<OracleConnector> _logger; | ||||
|  | ||||
|     public OracleConnector( | ||||
|         SourceFetchService fetchService, | ||||
|         RawDocumentStorage rawDocumentStorage, | ||||
|         IDocumentStore documentStore, | ||||
|         IDtoStore dtoStore, | ||||
|         IAdvisoryStore advisoryStore, | ||||
|         IPsirtFlagStore psirtFlagStore, | ||||
|         ISourceStateRepository stateRepository, | ||||
|         IOptions<OracleOptions> options, | ||||
|         TimeProvider? timeProvider, | ||||
|         ILogger<OracleConnector> logger) | ||||
|     { | ||||
|         _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService)); | ||||
|         _rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage)); | ||||
|         _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); | ||||
|         _dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore)); | ||||
|         _advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore)); | ||||
|         _psirtFlagStore = psirtFlagStore ?? throw new ArgumentNullException(nameof(psirtFlagStore)); | ||||
|         _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); | ||||
|         _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _options.Validate(); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public string SourceName => VndrOracleConnectorPlugin.SourceName; | ||||
|  | ||||
|     public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var pendingDocuments = cursor.PendingDocuments.ToList(); | ||||
|         var pendingMappings = cursor.PendingMappings.ToList(); | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|  | ||||
|         foreach (var uri in _options.AdvisoryUris) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var advisoryId = DeriveAdvisoryId(uri); | ||||
|                 var title = advisoryId.Replace('-', ' '); | ||||
|                 var published = now; | ||||
|  | ||||
|                 var metadata = OracleDocumentMetadata.CreateMetadata(advisoryId, title, published); | ||||
|                 var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, uri.ToString(), cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|                 var request = new SourceFetchRequest(OracleOptions.HttpClientName, SourceName, uri) | ||||
|                 { | ||||
|                     Metadata = metadata, | ||||
|                     ETag = existing?.Etag, | ||||
|                     LastModified = existing?.LastModified, | ||||
|                     AcceptHeaders = new[] { "text/html", "application/xhtml+xml", "text/plain;q=0.5" }, | ||||
|                 }; | ||||
|  | ||||
|                 var result = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|                 if (!result.IsSuccess || result.Document is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!pendingDocuments.Contains(result.Document.Id)) | ||||
|                 { | ||||
|                     pendingDocuments.Add(result.Document.Id); | ||||
|                 } | ||||
|  | ||||
|                 if (_options.RequestDelay > TimeSpan.Zero) | ||||
|                 { | ||||
|                     await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false); | ||||
|                 } | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Oracle fetch failed for {Uri}", uri); | ||||
|                 await _stateRepository.MarkFailureAsync(SourceName, _timeProvider.GetUtcNow(), TimeSpan.FromMinutes(10), ex.Message, cancellationToken).ConfigureAwait(false); | ||||
|                 throw; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var updatedCursor = cursor | ||||
|             .WithPendingDocuments(pendingDocuments) | ||||
|             .WithPendingMappings(pendingMappings) | ||||
|             .WithLastProcessed(now); | ||||
|  | ||||
|         await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false); | ||||
|         if (cursor.PendingDocuments.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var pendingDocuments = cursor.PendingDocuments.ToList(); | ||||
|         var pendingMappings = cursor.PendingMappings.ToList(); | ||||
|  | ||||
|         foreach (var documentId in cursor.PendingDocuments) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false); | ||||
|             if (document is null) | ||||
|             { | ||||
|                 pendingDocuments.Remove(documentId); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!document.GridFsId.HasValue) | ||||
|             { | ||||
|                 _logger.LogWarning("Oracle document {DocumentId} missing GridFS payload", document.Id); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingDocuments.Remove(documentId); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             OracleDto dto; | ||||
|             try | ||||
|             { | ||||
|                 var metadata = OracleDocumentMetadata.FromDocument(document); | ||||
|                 var content = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false); | ||||
|                 var html = System.Text.Encoding.UTF8.GetString(content); | ||||
|                 dto = OracleParser.Parse(html, metadata); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Oracle parse failed for document {DocumentId}", document.Id); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingDocuments.Remove(documentId); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var json = JsonSerializer.Serialize(dto, SerializerOptions); | ||||
|             var payload = BsonDocument.Parse(json); | ||||
|             var validatedAt = _timeProvider.GetUtcNow(); | ||||
|  | ||||
|             var existingDto = await _dtoStore.FindByDocumentIdAsync(document.Id, cancellationToken).ConfigureAwait(false); | ||||
|             var dtoRecord = existingDto is null | ||||
|                 ? new DtoRecord(Guid.NewGuid(), document.Id, SourceName, "oracle.advisory.v1", payload, validatedAt) | ||||
|                 : existingDto with | ||||
|                 { | ||||
|                     Payload = payload, | ||||
|                     SchemaVersion = "oracle.advisory.v1", | ||||
|                     ValidatedAt = validatedAt, | ||||
|                 }; | ||||
|  | ||||
|             await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false); | ||||
|             await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             pendingDocuments.Remove(documentId); | ||||
|             if (!pendingMappings.Contains(documentId)) | ||||
|             { | ||||
|                 pendingMappings.Add(documentId); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var updatedCursor = cursor | ||||
|             .WithPendingDocuments(pendingDocuments) | ||||
|             .WithPendingMappings(pendingMappings); | ||||
|  | ||||
|         await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false); | ||||
|         if (cursor.PendingMappings.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var pendingMappings = cursor.PendingMappings.ToList(); | ||||
|  | ||||
|         foreach (var documentId in cursor.PendingMappings) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false); | ||||
|             var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             if (dtoRecord is null || document is null) | ||||
|             { | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             OracleDto? dto; | ||||
|             try | ||||
|             { | ||||
|                 var json = dtoRecord.Payload.ToJson(); | ||||
|                 dto = JsonSerializer.Deserialize<OracleDto>(json, SerializerOptions); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Oracle DTO deserialization failed for document {DocumentId}", documentId); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (dto is null) | ||||
|             { | ||||
|                 _logger.LogWarning("Oracle DTO payload deserialized as null for document {DocumentId}", documentId); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var mappedAt = _timeProvider.GetUtcNow(); | ||||
|             var (advisory, flag) = OracleMapper.Map(dto, SourceName, mappedAt); | ||||
|             await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false); | ||||
|             await _psirtFlagStore.UpsertAsync(flag, cancellationToken).ConfigureAwait(false); | ||||
|             await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             pendingMappings.Remove(documentId); | ||||
|         } | ||||
|  | ||||
|         var updatedCursor = cursor.WithPendingMappings(pendingMappings); | ||||
|         await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private async Task<OracleCursor> GetCursorAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var record = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false); | ||||
|         return OracleCursor.FromBson(record?.Cursor); | ||||
|     } | ||||
|  | ||||
|     private async Task UpdateCursorAsync(OracleCursor cursor, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var completedAt = _timeProvider.GetUtcNow(); | ||||
|         await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), completedAt, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private static string DeriveAdvisoryId(Uri uri) | ||||
|     { | ||||
|         var segments = uri.Segments; | ||||
|         if (segments.Length == 0) | ||||
|         { | ||||
|             return uri.AbsoluteUri; | ||||
|         } | ||||
|  | ||||
|         var slug = segments[^1].Trim('/'); | ||||
|         if (string.IsNullOrWhiteSpace(slug)) | ||||
|         { | ||||
|             return uri.AbsoluteUri; | ||||
|         } | ||||
|  | ||||
|         return slug.Replace('.', '-'); | ||||
|     } | ||||
| } | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Text.Json; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Feedser.Source.Common; | ||||
| using StellaOps.Feedser.Source.Common.Fetch; | ||||
| using StellaOps.Feedser.Source.Vndr.Oracle.Configuration; | ||||
| using StellaOps.Feedser.Source.Vndr.Oracle.Internal; | ||||
| using StellaOps.Feedser.Storage.Mongo; | ||||
| using StellaOps.Feedser.Storage.Mongo.Advisories; | ||||
| using StellaOps.Feedser.Storage.Mongo.Documents; | ||||
| using StellaOps.Feedser.Storage.Mongo.Dtos; | ||||
| using StellaOps.Feedser.Storage.Mongo.PsirtFlags; | ||||
| using StellaOps.Plugin; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.Vndr.Oracle; | ||||
|  | ||||
| public sealed class OracleConnector : IFeedConnector | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new() | ||||
|     { | ||||
|         PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||
|         DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, | ||||
|     }; | ||||
|  | ||||
|     private readonly SourceFetchService _fetchService; | ||||
|     private readonly RawDocumentStorage _rawDocumentStorage; | ||||
|     private readonly IDocumentStore _documentStore; | ||||
|     private readonly IDtoStore _dtoStore; | ||||
|     private readonly IAdvisoryStore _advisoryStore; | ||||
|     private readonly IPsirtFlagStore _psirtFlagStore; | ||||
|     private readonly ISourceStateRepository _stateRepository; | ||||
|     private readonly OracleOptions _options; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly ILogger<OracleConnector> _logger; | ||||
|  | ||||
|     public OracleConnector( | ||||
|         SourceFetchService fetchService, | ||||
|         RawDocumentStorage rawDocumentStorage, | ||||
|         IDocumentStore documentStore, | ||||
|         IDtoStore dtoStore, | ||||
|         IAdvisoryStore advisoryStore, | ||||
|         IPsirtFlagStore psirtFlagStore, | ||||
|         ISourceStateRepository stateRepository, | ||||
|         IOptions<OracleOptions> options, | ||||
|         TimeProvider? timeProvider, | ||||
|         ILogger<OracleConnector> logger) | ||||
|     { | ||||
|         _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService)); | ||||
|         _rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage)); | ||||
|         _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); | ||||
|         _dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore)); | ||||
|         _advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore)); | ||||
|         _psirtFlagStore = psirtFlagStore ?? throw new ArgumentNullException(nameof(psirtFlagStore)); | ||||
|         _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); | ||||
|         _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _options.Validate(); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public string SourceName => VndrOracleConnectorPlugin.SourceName; | ||||
|  | ||||
|     public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var pendingDocuments = cursor.PendingDocuments.ToList(); | ||||
|         var pendingMappings = cursor.PendingMappings.ToList(); | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|  | ||||
|         foreach (var uri in _options.AdvisoryUris) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var advisoryId = DeriveAdvisoryId(uri); | ||||
|                 var title = advisoryId.Replace('-', ' '); | ||||
|                 var published = now; | ||||
|  | ||||
|                 var metadata = OracleDocumentMetadata.CreateMetadata(advisoryId, title, published); | ||||
|                 var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, uri.ToString(), cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|                 var request = new SourceFetchRequest(OracleOptions.HttpClientName, SourceName, uri) | ||||
|                 { | ||||
|                     Metadata = metadata, | ||||
|                     ETag = existing?.Etag, | ||||
|                     LastModified = existing?.LastModified, | ||||
|                     AcceptHeaders = new[] { "text/html", "application/xhtml+xml", "text/plain;q=0.5" }, | ||||
|                 }; | ||||
|  | ||||
|                 var result = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|                 if (!result.IsSuccess || result.Document is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!pendingDocuments.Contains(result.Document.Id)) | ||||
|                 { | ||||
|                     pendingDocuments.Add(result.Document.Id); | ||||
|                 } | ||||
|  | ||||
|                 if (_options.RequestDelay > TimeSpan.Zero) | ||||
|                 { | ||||
|                     await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false); | ||||
|                 } | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Oracle fetch failed for {Uri}", uri); | ||||
|                 await _stateRepository.MarkFailureAsync(SourceName, _timeProvider.GetUtcNow(), TimeSpan.FromMinutes(10), ex.Message, cancellationToken).ConfigureAwait(false); | ||||
|                 throw; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var updatedCursor = cursor | ||||
|             .WithPendingDocuments(pendingDocuments) | ||||
|             .WithPendingMappings(pendingMappings) | ||||
|             .WithLastProcessed(now); | ||||
|  | ||||
|         await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false); | ||||
|         if (cursor.PendingDocuments.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var pendingDocuments = cursor.PendingDocuments.ToList(); | ||||
|         var pendingMappings = cursor.PendingMappings.ToList(); | ||||
|  | ||||
|         foreach (var documentId in cursor.PendingDocuments) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false); | ||||
|             if (document is null) | ||||
|             { | ||||
|                 pendingDocuments.Remove(documentId); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!document.GridFsId.HasValue) | ||||
|             { | ||||
|                 _logger.LogWarning("Oracle document {DocumentId} missing GridFS payload", document.Id); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingDocuments.Remove(documentId); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             OracleDto dto; | ||||
|             try | ||||
|             { | ||||
|                 var metadata = OracleDocumentMetadata.FromDocument(document); | ||||
|                 var content = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false); | ||||
|                 var html = System.Text.Encoding.UTF8.GetString(content); | ||||
|                 dto = OracleParser.Parse(html, metadata); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Oracle parse failed for document {DocumentId}", document.Id); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingDocuments.Remove(documentId); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var json = JsonSerializer.Serialize(dto, SerializerOptions); | ||||
|             var payload = BsonDocument.Parse(json); | ||||
|             var validatedAt = _timeProvider.GetUtcNow(); | ||||
|  | ||||
|             var existingDto = await _dtoStore.FindByDocumentIdAsync(document.Id, cancellationToken).ConfigureAwait(false); | ||||
|             var dtoRecord = existingDto is null | ||||
|                 ? new DtoRecord(Guid.NewGuid(), document.Id, SourceName, "oracle.advisory.v1", payload, validatedAt) | ||||
|                 : existingDto with | ||||
|                 { | ||||
|                     Payload = payload, | ||||
|                     SchemaVersion = "oracle.advisory.v1", | ||||
|                     ValidatedAt = validatedAt, | ||||
|                 }; | ||||
|  | ||||
|             await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false); | ||||
|             await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             pendingDocuments.Remove(documentId); | ||||
|             if (!pendingMappings.Contains(documentId)) | ||||
|             { | ||||
|                 pendingMappings.Add(documentId); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var updatedCursor = cursor | ||||
|             .WithPendingDocuments(pendingDocuments) | ||||
|             .WithPendingMappings(pendingMappings); | ||||
|  | ||||
|         await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false); | ||||
|         if (cursor.PendingMappings.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var pendingMappings = cursor.PendingMappings.ToList(); | ||||
|  | ||||
|         foreach (var documentId in cursor.PendingMappings) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false); | ||||
|             var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             if (dtoRecord is null || document is null) | ||||
|             { | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             OracleDto? dto; | ||||
|             try | ||||
|             { | ||||
|                 var json = dtoRecord.Payload.ToJson(); | ||||
|                 dto = JsonSerializer.Deserialize<OracleDto>(json, SerializerOptions); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Oracle DTO deserialization failed for document {DocumentId}", documentId); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (dto is null) | ||||
|             { | ||||
|                 _logger.LogWarning("Oracle DTO payload deserialized as null for document {DocumentId}", documentId); | ||||
|                 await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false); | ||||
|                 pendingMappings.Remove(documentId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var mappedAt = _timeProvider.GetUtcNow(); | ||||
|             var (advisory, flag) = OracleMapper.Map(dto, SourceName, mappedAt); | ||||
|             await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false); | ||||
|             await _psirtFlagStore.UpsertAsync(flag, cancellationToken).ConfigureAwait(false); | ||||
|             await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             pendingMappings.Remove(documentId); | ||||
|         } | ||||
|  | ||||
|         var updatedCursor = cursor.WithPendingMappings(pendingMappings); | ||||
|         await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private async Task<OracleCursor> GetCursorAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var record = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false); | ||||
|         return OracleCursor.FromBson(record?.Cursor); | ||||
|     } | ||||
|  | ||||
|     private async Task UpdateCursorAsync(OracleCursor cursor, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var completedAt = _timeProvider.GetUtcNow(); | ||||
|         await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), completedAt, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private static string DeriveAdvisoryId(Uri uri) | ||||
|     { | ||||
|         var segments = uri.Segments; | ||||
|         if (segments.Length == 0) | ||||
|         { | ||||
|             return uri.AbsoluteUri; | ||||
|         } | ||||
|  | ||||
|         var slug = segments[^1].Trim('/'); | ||||
|         if (string.IsNullOrWhiteSpace(slug)) | ||||
|         { | ||||
|             return uri.AbsoluteUri; | ||||
|         } | ||||
|  | ||||
|         return slug.Replace('.', '-'); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,21 +1,21 @@ | ||||
| using System; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using StellaOps.Plugin; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.Vndr.Oracle; | ||||
|  | ||||
| public sealed class VndrOracleConnectorPlugin : IConnectorPlugin | ||||
| { | ||||
|     public const string SourceName = "vndr-oracle"; | ||||
|  | ||||
|     public string Name => SourceName; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) | ||||
|         => services.GetService<OracleConnector>() is not null; | ||||
|  | ||||
|     public IFeedConnector Create(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         return services.GetRequiredService<OracleConnector>(); | ||||
|     } | ||||
| } | ||||
| using System; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using StellaOps.Plugin; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.Vndr.Oracle; | ||||
|  | ||||
| public sealed class VndrOracleConnectorPlugin : IConnectorPlugin | ||||
| { | ||||
|     public const string SourceName = "vndr-oracle"; | ||||
|  | ||||
|     public string Name => SourceName; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) | ||||
|         => services.GetService<OracleConnector>() is not null; | ||||
|  | ||||
|     public IFeedConnector Create(IServiceProvider services) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|         return services.GetRequiredService<OracleConnector>(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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,12 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||
|   </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,11 @@ | ||||
| <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> | ||||
| </Project> | ||||
| @@ -0,0 +1,37 @@ | ||||
| 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); | ||||
|     } | ||||
|  | ||||
|     [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); | ||||
|     } | ||||
| } | ||||
| @@ -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,65 @@ | ||||
| using System; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.Options; | ||||
|  | ||||
| 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; | ||||
|         }); | ||||
|  | ||||
|         services.AddHttpClient<StellaOpsJwksCache>((provider, client) => | ||||
|         { | ||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||
|             client.Timeout = options.HttpTimeout; | ||||
|         }); | ||||
|  | ||||
|         services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) => | ||||
|         { | ||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||
|             client.Timeout = options.HttpTimeout; | ||||
|         }); | ||||
|  | ||||
|         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; | ||||
|     } | ||||
| } | ||||
| @@ -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> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> | ||||
|       <_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1> | ||||
|     </AssemblyAttribute> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,143 @@ | ||||
| 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 readonly List<string> scopes = new(); | ||||
|  | ||||
|     /// <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> | ||||
|     /// 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> | ||||
|     /// 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> | ||||
|     /// 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."); | ||||
|         } | ||||
|  | ||||
|         AuthorityUri = authorityUri; | ||||
|         NormalizedScopes = NormalizeScopes(scopes); | ||||
|     } | ||||
|  | ||||
|     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(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,87 @@ | ||||
| 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; | ||||
|  | ||||
|     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); | ||||
|  | ||||
|         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; | ||||
|  | ||||
|         return configuration; | ||||
|     } | ||||
|  | ||||
|     private sealed record DiscoveryDocument( | ||||
|         [property: System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] string? TokenEndpoint, | ||||
|         [property: System.Text.Json.Serialization.JsonPropertyName("jwks_uri")] string? JwksUri); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Minimal OpenID Connect configuration representation. | ||||
| /// </summary> | ||||
| public sealed record OpenIdConfiguration(Uri TokenEndpoint, Uri JwksEndpoint); | ||||
| @@ -0,0 +1,60 @@ | ||||
| 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; | ||||
|  | ||||
|     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 configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         logger?.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksEndpoint); | ||||
|  | ||||
|         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 + optionsMonitor.CurrentValue.JwksCacheLifetime; | ||||
|  | ||||
|         return cachedSet; | ||||
|     } | ||||
| } | ||||
| @@ -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,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,23 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </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" /> | ||||
|   </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,56 @@ | ||||
| using System; | ||||
| 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); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,169 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Mongo2Go; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| 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>()); | ||||
|     } | ||||
| } | ||||
|  | ||||
| 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,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,93 @@ | ||||
| using System; | ||||
|  | ||||
| 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 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; } | ||||
| } | ||||
| @@ -0,0 +1,81 @@ | ||||
| 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>()); | ||||
|  | ||||
|         context.Services.AddOptions<StandardPluginOptions>(pluginName) | ||||
|             .Bind(context.Plugin.Configuration) | ||||
|             .PostConfigure(options => options.Validate(pluginName)); | ||||
|  | ||||
|         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,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); | ||||
| } | ||||
| @@ -0,0 +1,64 @@ | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Options; | ||||
|  | ||||
| /// <summary> | ||||
| /// Strongly typed configuration for the StellaOps Authority MongoDB storage layer. | ||||
| /// </summary> | ||||
| public sealed class AuthorityMongoOptions | ||||
| { | ||||
|     /// <summary> | ||||
|     /// MongoDB connection string used to bootstrap the client. | ||||
|     /// </summary> | ||||
|     public string ConnectionString { get; set; } = string.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional override for the database name. When omitted the database name embedded in the connection string is used. | ||||
|     /// </summary> | ||||
|     public string? DatabaseName { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Command timeout applied to MongoDB operations. | ||||
|     /// </summary> | ||||
|     public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Returns the resolved database name. | ||||
|     /// </summary> | ||||
|     public string GetDatabaseName() | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(DatabaseName)) | ||||
|         { | ||||
|             return DatabaseName.Trim(); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(ConnectionString)) | ||||
|         { | ||||
|             var url = MongoUrl.Create(ConnectionString); | ||||
|             if (!string.IsNullOrWhiteSpace(url.DatabaseName)) | ||||
|             { | ||||
|                 return url.DatabaseName; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return AuthorityMongoDefaults.DefaultDatabaseName; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Validates configured values and throws when invalid. | ||||
|     /// </summary> | ||||
|     public void EnsureValid() | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(ConnectionString)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority Mongo storage requires a connection string."); | ||||
|         } | ||||
|  | ||||
|         if (CommandTimeout <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority Mongo storage command timeout must be greater than zero."); | ||||
|         } | ||||
|  | ||||
|         _ = GetDatabaseName(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="2.22.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,64 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| internal sealed class AuthorityClientStore : IAuthorityClientStore | ||||
| { | ||||
|     private readonly IMongoCollection<AuthorityClientDocument> collection; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ILogger<AuthorityClientStore> logger; | ||||
|  | ||||
|     public AuthorityClientStore( | ||||
|         IMongoCollection<AuthorityClientDocument> collection, | ||||
|         TimeProvider clock, | ||||
|         ILogger<AuthorityClientStore> logger) | ||||
|     { | ||||
|         this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(clientId)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var id = clientId.Trim(); | ||||
|         return await collection.Find(c => c.ClientId == id) | ||||
|             .FirstOrDefaultAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         document.UpdatedAt = clock.GetUtcNow(); | ||||
|  | ||||
|         var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId); | ||||
|         var options = new ReplaceOptions { IsUpsert = true }; | ||||
|  | ||||
|         var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (result.UpsertedId is not null) | ||||
|         { | ||||
|             logger.LogInformation("Inserted Authority client {ClientId}.", document.ClientId); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(clientId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var id = clientId.Trim(); | ||||
|         var result = await collection.DeleteOneAsync(c => c.ClientId == id, cancellationToken).ConfigureAwait(false); | ||||
|         return result.DeletedCount > 0; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,51 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore | ||||
| { | ||||
|     private readonly IMongoCollection<AuthorityLoginAttemptDocument> collection; | ||||
|     private readonly ILogger<AuthorityLoginAttemptStore> logger; | ||||
|  | ||||
|     public AuthorityLoginAttemptStore( | ||||
|         IMongoCollection<AuthorityLoginAttemptDocument> collection, | ||||
|         ILogger<AuthorityLoginAttemptStore> logger) | ||||
|     { | ||||
|         this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|         logger.LogDebug( | ||||
|             "Recorded login attempt for subject '{SubjectId}' (success={Successful}).", | ||||
|             document.SubjectId ?? document.Username ?? "<unknown>", | ||||
|             document.Successful); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0) | ||||
|         { | ||||
|             return Array.Empty<AuthorityLoginAttemptDocument>(); | ||||
|         } | ||||
|  | ||||
|         var normalized = subjectId.Trim(); | ||||
|  | ||||
|         var cursor = await collection.FindAsync( | ||||
|             Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized), | ||||
|             new FindOptions<AuthorityLoginAttemptDocument> | ||||
|             { | ||||
|                 Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt), | ||||
|                 Limit = limit | ||||
|             }, | ||||
|             cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| internal sealed class AuthorityScopeStore : IAuthorityScopeStore | ||||
| { | ||||
|     private readonly IMongoCollection<AuthorityScopeDocument> collection; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ILogger<AuthorityScopeStore> logger; | ||||
|  | ||||
|     public AuthorityScopeStore( | ||||
|         IMongoCollection<AuthorityScopeDocument> collection, | ||||
|         TimeProvider clock, | ||||
|         ILogger<AuthorityScopeStore> logger) | ||||
|     { | ||||
|         this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var normalized = name.Trim(); | ||||
|         return await collection.Find(s => s.Name == normalized) | ||||
|             .FirstOrDefaultAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|         return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         document.UpdatedAt = clock.GetUtcNow(); | ||||
|  | ||||
|         var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name); | ||||
|         var options = new ReplaceOptions { IsUpsert = true }; | ||||
|  | ||||
|         var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false); | ||||
|         if (result.UpsertedId is not null) | ||||
|         { | ||||
|             logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var normalized = name.Trim(); | ||||
|         var result = await collection.DeleteOneAsync(s => s.Name == normalized, cancellationToken).ConfigureAwait(false); | ||||
|         return result.DeletedCount > 0; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,93 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| internal sealed class AuthorityTokenStore : IAuthorityTokenStore | ||||
| { | ||||
|     private readonly IMongoCollection<AuthorityTokenDocument> collection; | ||||
|     private readonly ILogger<AuthorityTokenStore> logger; | ||||
|  | ||||
|     public AuthorityTokenStore( | ||||
|         IMongoCollection<AuthorityTokenDocument> collection, | ||||
|         ILogger<AuthorityTokenStore> logger) | ||||
|     { | ||||
|         this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|         logger.LogDebug("Inserted Authority token {TokenId}.", document.TokenId); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(tokenId)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var id = tokenId.Trim(); | ||||
|         return await collection.Find(t => t.TokenId == id) | ||||
|             .FirstOrDefaultAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(referenceId)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var id = referenceId.Trim(); | ||||
|         return await collection.Find(t => t.ReferenceId == id) | ||||
|             .FirstOrDefaultAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(tokenId)) | ||||
|         { | ||||
|             throw new ArgumentException("Token id cannot be empty.", nameof(tokenId)); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(status)) | ||||
|         { | ||||
|             throw new ArgumentException("Status cannot be empty.", nameof(status)); | ||||
|         } | ||||
|  | ||||
|         var update = Builders<AuthorityTokenDocument>.Update | ||||
|             .Set(t => t.Status, status) | ||||
|             .Set(t => t.RevokedAt, revokedAt); | ||||
|  | ||||
|         var result = await collection.UpdateOneAsync( | ||||
|             Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()), | ||||
|             update, | ||||
|             cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         logger.LogDebug("Updated token {TokenId} status to {Status} (matched {Matched}).", tokenId, status, result.MatchedCount); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var filter = Builders<AuthorityTokenDocument>.Filter.And( | ||||
|             Builders<AuthorityTokenDocument>.Filter.Not( | ||||
|                 Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked")), | ||||
|             Builders<AuthorityTokenDocument>.Filter.Lt(t => t.ExpiresAt, threshold)); | ||||
|  | ||||
|         var result = await collection.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false); | ||||
|         if (result.DeletedCount > 0) | ||||
|         { | ||||
|             logger.LogInformation("Deleted {Count} expired Authority tokens.", result.DeletedCount); | ||||
|         } | ||||
|  | ||||
|         return result.DeletedCount; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,81 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| internal sealed class AuthorityUserStore : IAuthorityUserStore | ||||
| { | ||||
|     private readonly IMongoCollection<AuthorityUserDocument> collection; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ILogger<AuthorityUserStore> logger; | ||||
|  | ||||
|     public AuthorityUserStore( | ||||
|         IMongoCollection<AuthorityUserDocument> collection, | ||||
|         TimeProvider clock, | ||||
|         ILogger<AuthorityUserStore> logger) | ||||
|     { | ||||
|         this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(subjectId)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return await collection | ||||
|             .Find(u => u.SubjectId == subjectId) | ||||
|             .FirstOrDefaultAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(normalizedUsername)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var normalised = normalizedUsername.Trim(); | ||||
|  | ||||
|         return await collection | ||||
|             .Find(u => u.NormalizedUsername == normalised) | ||||
|             .FirstOrDefaultAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         document.UpdatedAt = clock.GetUtcNow(); | ||||
|  | ||||
|         var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId); | ||||
|         var options = new ReplaceOptions { IsUpsert = true }; | ||||
|  | ||||
|         var result = await collection | ||||
|             .ReplaceOneAsync(filter, document, options, cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         if (result.UpsertedId is not null) | ||||
|         { | ||||
|             logger.LogInformation("Inserted Authority user {SubjectId}.", document.SubjectId); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(subjectId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var normalised = subjectId.Trim(); | ||||
|         var result = await collection.DeleteOneAsync(u => u.SubjectId == normalised, cancellationToken).ConfigureAwait(false); | ||||
|         return result.DeletedCount > 0; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| public interface IAuthorityClientStore | ||||
| { | ||||
|     ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken); | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user