Restructure solution layout by module
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
root
2025-10-28 15:10:40 +02:00
parent 4e3e575db5
commit 68da90a11a
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,303 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{336F7E73-0D75-4308-A20B-E8AB7964D27C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions.Tests", "StellaOps.Authority\StellaOps.Auth.Abstractions.Tests\StellaOps.Auth.Abstractions.Tests.csproj", "{CD7D0B36-386B-455D-A14B-E7857C255C42}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{F2CEB8F7-C65B-407E-A11F-B02A39237355}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{BF48C3E7-E1E8-4869-973F-22554F146FCE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{91C7B100-D04A-4486-8A26-9D55234876D7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{00E2F0AF-32EC-4755-81AD-907532F48BBB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client.Tests", "StellaOps.Authority\StellaOps.Auth.Client.Tests\StellaOps.Auth.Client.Tests.csproj", "{2346E499-C1F4-46C5-BB03-859FC56881D4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{412DAFA7-FDEA-418C-995B-7C7F51D89E00}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{79CB2323-2370-419A-8B22-A193B3F3CE68}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration.Tests", "StellaOps.Authority\StellaOps.Auth.ServerIntegration.Tests\StellaOps.Auth.ServerIntegration.Tests.csproj", "{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority", "StellaOps.Authority\StellaOps.Authority\StellaOps.Authority.csproj", "{614EDC46-4654-40F7-A779-8F127B8FD956}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard", "StellaOps.Authority\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj", "{4B12E120-E39B-44A7-A25E-D3151D5AE914}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Storage.Mongo", "StellaOps.Authority\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj", "{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard.Tests", "StellaOps.Authority\StellaOps.Authority.Plugin.Standard.Tests\StellaOps.Authority.Plugin.Standard.Tests.csproj", "{168986E2-E127-4E03-BE45-4CC306E4E880}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions.Tests", "StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions.Tests\StellaOps.Authority.Plugins.Abstractions.Tests.csproj", "{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Tests", "StellaOps.Authority\StellaOps.Authority.Tests\StellaOps.Authority.Tests.csproj", "{24BBDF59-7B30-4620-8464-BDACB1AEF49D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Debug|x64.ActiveCfg = Debug|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Debug|x64.Build.0 = Debug|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Debug|x86.ActiveCfg = Debug|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Debug|x86.Build.0 = Debug|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Release|Any CPU.Build.0 = Release|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Release|x64.ActiveCfg = Release|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Release|x64.Build.0 = Release|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Release|x86.ActiveCfg = Release|Any CPU
{336F7E73-0D75-4308-A20B-E8AB7964D27C}.Release|x86.Build.0 = Release|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Debug|x64.ActiveCfg = Debug|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Debug|x64.Build.0 = Debug|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Debug|x86.ActiveCfg = Debug|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Debug|x86.Build.0 = Debug|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Release|Any CPU.Build.0 = Release|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Release|x64.ActiveCfg = Release|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Release|x64.Build.0 = Release|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Release|x86.ActiveCfg = Release|Any CPU
{CD7D0B36-386B-455D-A14B-E7857C255C42}.Release|x86.Build.0 = Release|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Debug|x64.ActiveCfg = Debug|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Debug|x64.Build.0 = Debug|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Debug|x86.ActiveCfg = Debug|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Debug|x86.Build.0 = Debug|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Release|Any CPU.Build.0 = Release|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Release|x64.ActiveCfg = Release|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Release|x64.Build.0 = Release|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Release|x86.ActiveCfg = Release|Any CPU
{F2CEB8F7-C65B-407E-A11F-B02A39237355}.Release|x86.Build.0 = Release|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Debug|x64.ActiveCfg = Debug|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Debug|x64.Build.0 = Debug|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Debug|x86.ActiveCfg = Debug|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Debug|x86.Build.0 = Debug|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Release|Any CPU.Build.0 = Release|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Release|x64.ActiveCfg = Release|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Release|x64.Build.0 = Release|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Release|x86.ActiveCfg = Release|Any CPU
{BF48C3E7-E1E8-4869-973F-22554F146FCE}.Release|x86.Build.0 = Release|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Debug|x64.ActiveCfg = Debug|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Debug|x64.Build.0 = Debug|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Debug|x86.ActiveCfg = Debug|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Debug|x86.Build.0 = Debug|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Release|Any CPU.Build.0 = Release|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Release|x64.ActiveCfg = Release|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Release|x64.Build.0 = Release|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Release|x86.ActiveCfg = Release|Any CPU
{91C7B100-D04A-4486-8A26-9D55234876D7}.Release|x86.Build.0 = Release|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Debug|x64.ActiveCfg = Debug|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Debug|x64.Build.0 = Debug|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Debug|x86.ActiveCfg = Debug|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Debug|x86.Build.0 = Debug|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Release|Any CPU.Build.0 = Release|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Release|x64.ActiveCfg = Release|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Release|x64.Build.0 = Release|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Release|x86.ActiveCfg = Release|Any CPU
{00E2F0AF-32EC-4755-81AD-907532F48BBB}.Release|x86.Build.0 = Release|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Debug|x64.ActiveCfg = Debug|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Debug|x64.Build.0 = Debug|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Debug|x86.ActiveCfg = Debug|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Debug|x86.Build.0 = Debug|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Release|Any CPU.Build.0 = Release|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Release|x64.ActiveCfg = Release|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Release|x64.Build.0 = Release|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Release|x86.ActiveCfg = Release|Any CPU
{2346E499-C1F4-46C5-BB03-859FC56881D4}.Release|x86.Build.0 = Release|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Debug|Any CPU.Build.0 = Debug|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Debug|x64.ActiveCfg = Debug|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Debug|x64.Build.0 = Debug|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Debug|x86.ActiveCfg = Debug|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Debug|x86.Build.0 = Debug|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Release|Any CPU.ActiveCfg = Release|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Release|Any CPU.Build.0 = Release|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Release|x64.ActiveCfg = Release|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Release|x64.Build.0 = Release|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Release|x86.ActiveCfg = Release|Any CPU
{412DAFA7-FDEA-418C-995B-7C7F51D89E00}.Release|x86.Build.0 = Release|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Debug|x64.ActiveCfg = Debug|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Debug|x64.Build.0 = Debug|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Debug|x86.ActiveCfg = Debug|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Debug|x86.Build.0 = Debug|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Release|Any CPU.Build.0 = Release|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Release|x64.ActiveCfg = Release|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Release|x64.Build.0 = Release|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Release|x86.ActiveCfg = Release|Any CPU
{79CB2323-2370-419A-8B22-A193B3F3CE68}.Release|x86.Build.0 = Release|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Debug|x64.ActiveCfg = Debug|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Debug|x64.Build.0 = Debug|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Debug|x86.ActiveCfg = Debug|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Debug|x86.Build.0 = Debug|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Release|Any CPU.Build.0 = Release|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Release|x64.ActiveCfg = Release|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Release|x64.Build.0 = Release|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Release|x86.ActiveCfg = Release|Any CPU
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3}.Release|x86.Build.0 = Release|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Debug|Any CPU.Build.0 = Debug|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Debug|x64.ActiveCfg = Debug|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Debug|x64.Build.0 = Debug|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Debug|x86.ActiveCfg = Debug|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Debug|x86.Build.0 = Debug|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Release|Any CPU.ActiveCfg = Release|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Release|Any CPU.Build.0 = Release|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Release|x64.ActiveCfg = Release|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Release|x64.Build.0 = Release|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Release|x86.ActiveCfg = Release|Any CPU
{614EDC46-4654-40F7-A779-8F127B8FD956}.Release|x86.Build.0 = Release|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Debug|x64.ActiveCfg = Debug|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Debug|x64.Build.0 = Debug|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Debug|x86.ActiveCfg = Debug|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Debug|x86.Build.0 = Debug|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Release|Any CPU.Build.0 = Release|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Release|x64.ActiveCfg = Release|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Release|x64.Build.0 = Release|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Release|x86.ActiveCfg = Release|Any CPU
{4B12E120-E39B-44A7-A25E-D3151D5AE914}.Release|x86.Build.0 = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Debug|x64.ActiveCfg = Debug|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Debug|x64.Build.0 = Debug|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Debug|x86.ActiveCfg = Debug|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Debug|x86.Build.0 = Debug|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|Any CPU.Build.0 = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x64.ActiveCfg = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x64.Build.0 = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x86.ActiveCfg = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x86.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x64.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x64.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x86.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x86.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|Any CPU.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x64.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x64.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x86.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x86.Build.0 = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|x64.ActiveCfg = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|x64.Build.0 = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|x86.ActiveCfg = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|x86.Build.0 = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Release|Any CPU.Build.0 = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Release|x64.ActiveCfg = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Release|x64.Build.0 = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Release|x86.ActiveCfg = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Release|x86.Build.0 = Release|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Debug|x64.ActiveCfg = Debug|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Debug|x64.Build.0 = Debug|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Debug|x86.ActiveCfg = Debug|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Debug|x86.Build.0 = Debug|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Release|Any CPU.Build.0 = Release|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Release|x64.ActiveCfg = Release|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Release|x64.Build.0 = Release|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Release|x86.ActiveCfg = Release|Any CPU
{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}.Release|x86.Build.0 = Release|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Debug|Any CPU.Build.0 = Debug|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Debug|x64.ActiveCfg = Debug|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Debug|x64.Build.0 = Debug|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Debug|x86.ActiveCfg = Debug|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Debug|x86.Build.0 = Debug|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Release|Any CPU.ActiveCfg = Release|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Release|Any CPU.Build.0 = Release|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Release|x64.ActiveCfg = Release|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Release|x64.Build.0 = Release|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Release|x86.ActiveCfg = Release|Any CPU
{168986E2-E127-4E03-BE45-4CC306E4E880}.Release|x86.Build.0 = Release|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Debug|x64.ActiveCfg = Debug|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Debug|x64.Build.0 = Debug|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Debug|x86.ActiveCfg = Debug|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Debug|x86.Build.0 = Debug|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Release|Any CPU.Build.0 = Release|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Release|x64.ActiveCfg = Release|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Release|x64.Build.0 = Release|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Release|x86.ActiveCfg = Release|Any CPU
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3}.Release|x86.Build.0 = Release|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Debug|x64.ActiveCfg = Debug|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Debug|x64.Build.0 = Debug|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Debug|x86.ActiveCfg = Debug|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Debug|x86.Build.0 = Debug|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Release|Any CPU.Build.0 = Release|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Release|x64.ActiveCfg = Release|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Release|x64.Build.0 = Release|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Release|x86.ActiveCfg = Release|Any CPU
{24BBDF59-7B30-4620-8464-BDACB1AEF49D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{336F7E73-0D75-4308-A20B-E8AB7964D27C} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{CD7D0B36-386B-455D-A14B-E7857C255C42} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{F2CEB8F7-C65B-407E-A11F-B02A39237355} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{91C7B100-D04A-4486-8A26-9D55234876D7} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{2346E499-C1F4-46C5-BB03-859FC56881D4} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{412DAFA7-FDEA-418C-995B-7C7F51D89E00} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{614EDC46-4654-40F7-A779-8F127B8FD956} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{4B12E120-E39B-44A7-A25E-D3151D5AE914} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{168986E2-E127-4E03-BE45-4CC306E4E880} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{24BBDF59-7B30-4620-8464-BDACB1AEF49D} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
EndGlobalSection
EndGlobal

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.ConcelierJobsTrigger },
new[] { StellaOpsScopes.AuthorityUsersManage },
instance: "/jobs/trigger");
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
Assert.Equal("insufficient_scope", details.Extensions["error"]);
Assert.Equal(new[] { StellaOpsScopes.ConcelierJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
Assert.Equal("/jobs/trigger", details.Instance);
}
}

View File

@@ -0,0 +1,54 @@
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsScopesTests
{
[Theory]
[InlineData(StellaOpsScopes.AdvisoryRead)]
[InlineData(StellaOpsScopes.AdvisoryIngest)]
[InlineData(StellaOpsScopes.VexRead)]
[InlineData(StellaOpsScopes.VexIngest)]
[InlineData(StellaOpsScopes.AocVerify)]
[InlineData(StellaOpsScopes.SignalsRead)]
[InlineData(StellaOpsScopes.SignalsWrite)]
[InlineData(StellaOpsScopes.SignalsAdmin)]
[InlineData(StellaOpsScopes.PolicyWrite)]
[InlineData(StellaOpsScopes.PolicyAuthor)]
[InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyReview)]
[InlineData(StellaOpsScopes.PolicyOperate)]
[InlineData(StellaOpsScopes.PolicyAudit)]
[InlineData(StellaOpsScopes.PolicyRun)]
[InlineData(StellaOpsScopes.PolicySimulate)]
[InlineData(StellaOpsScopes.FindingsRead)]
[InlineData(StellaOpsScopes.EffectiveWrite)]
[InlineData(StellaOpsScopes.GraphRead)]
[InlineData(StellaOpsScopes.VulnRead)]
[InlineData(StellaOpsScopes.GraphWrite)]
[InlineData(StellaOpsScopes.GraphExport)]
[InlineData(StellaOpsScopes.GraphSimulate)]
[InlineData(StellaOpsScopes.OrchRead)]
[InlineData(StellaOpsScopes.OrchOperate)]
[InlineData(StellaOpsScopes.ExportViewer)]
[InlineData(StellaOpsScopes.ExportOperator)]
[InlineData(StellaOpsScopes.ExportAdmin)]
public void All_IncludesNewScopes(string scope)
{
Assert.Contains(scope, StellaOpsScopes.All);
}
[Theory]
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
[InlineData("Policy:Author", StellaOpsScopes.PolicyAuthor)]
[InlineData("Export.Admin", StellaOpsScopes.ExportAdmin)]
public void Normalize_NormalizesToLowerCase(string input, string expected)
{
Assert.Equal(expected, StellaOpsScopes.Normalize(input));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
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>
/// StellaOps project identifier claim (optional project scoping within a tenant).
/// </summary>
public const string Project = "stellaops:project";
/// <summary>
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
/// </summary>
public const string ClientId = "client_id";
/// <summary>
/// Unique token identifier claim (maps to <c>jti</c>).
/// </summary>
public const string TokenId = "jti";
/// <summary>
/// Authentication method reference claim (<c>amr</c>).
/// </summary>
public const string AuthenticationMethod = "amr";
/// <summary>
/// Space separated scope list (<c>scope</c>).
/// </summary>
public const string Scope = "scope";
/// <summary>
/// Individual scope items (<c>scp</c>).
/// </summary>
public const string ScopeItem = "scp";
/// <summary>
/// OAuth2 resource audiences (<c>aud</c>).
/// </summary>
public const string Audience = "aud";
/// <summary>
/// Identity provider hint for downstream services.
/// </summary>
public const string IdentityProvider = "stellaops:idp";
/// <summary>
/// Session identifier claim (<c>sid</c>).
/// </summary>
public const string SessionId = "sid";
}

View File

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

View File

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

View File

@@ -0,0 +1,289 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Concelier jobs.
/// </summary>
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
/// <summary>
/// Scope required to manage Concelier merge operations.
/// </summary>
public const string ConcelierMerge = "concelier.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
/// <summary>
/// Scope granting read-only access to console UX features.
/// </summary>
public const string UiRead = "ui.read";
/// <summary>
/// Scope granting permission to approve exceptions.
/// </summary>
public const string ExceptionsApprove = "exceptions:approve";
/// <summary>
/// Scope granting read-only access to raw advisory ingestion data.
/// </summary>
public const string AdvisoryRead = "advisory:read";
/// <summary>
/// Scope granting write access for raw advisory ingestion.
/// </summary>
public const string AdvisoryIngest = "advisory:ingest";
/// <summary>
/// Scope granting read-only access to raw VEX ingestion data.
/// </summary>
public const string VexRead = "vex:read";
/// <summary>
/// Scope granting write access for raw VEX ingestion.
/// </summary>
public const string VexIngest = "vex:ingest";
/// <summary>
/// Scope granting permission to execute aggregation-only contract verification.
/// </summary>
public const string AocVerify = "aoc:verify";
/// <summary>
/// Scope granting read-only access to reachability signals.
/// </summary>
public const string SignalsRead = "signals:read";
/// <summary>
/// Scope granting permission to write reachability signals.
/// </summary>
public const string SignalsWrite = "signals:write";
/// <summary>
/// Scope granting administrative access to reachability signal ingestion.
/// </summary>
public const string SignalsAdmin = "signals:admin";
/// <summary>
/// Scope granting permission to create or edit policy drafts.
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// Scope granting permission to author Policy Studio workspaces.
/// </summary>
public const string PolicyAuthor = "policy:author";
/// <summary>
/// Scope granting permission to edit policy configurations.
/// </summary>
public const string PolicyEdit = "policy:edit";
/// <summary>
/// Scope granting read-only access to policy metadata.
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// Scope granting permission to review Policy Studio drafts.
/// </summary>
public const string PolicyReview = "policy:review";
/// <summary>
/// Scope granting permission to submit drafts for review.
/// </summary>
public const string PolicySubmit = "policy:submit";
/// <summary>
/// Scope granting permission to approve or reject policies.
/// </summary>
public const string PolicyApprove = "policy:approve";
/// <summary>
/// Scope granting permission to operate Policy Studio promotions and runs.
/// </summary>
public const string PolicyOperate = "policy:operate";
/// <summary>
/// Scope granting permission to audit Policy Studio activity.
/// </summary>
public const string PolicyAudit = "policy:audit";
/// <summary>
/// Scope granting permission to trigger policy runs and activation workflows.
/// </summary>
public const string PolicyRun = "policy:run";
/// <summary>
/// Scope granting permission to activate policies.
/// </summary>
public const string PolicyActivate = "policy:activate";
/// <summary>
/// Scope granting read-only access to effective findings materialised by Policy Engine.
/// </summary>
public const string FindingsRead = "findings:read";
/// <summary>
/// Scope granting permission to run Policy Studio simulations.
/// </summary>
public const string PolicySimulate = "policy:simulate";
/// <summary>
/// Scope granted to Policy Engine service identity for writing effective findings.
/// </summary>
public const string EffectiveWrite = "effective:write";
/// <summary>
/// Scope granting read-only access to graph queries and overlays.
/// </summary>
public const string GraphRead = "graph:read";
/// <summary>
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
/// </summary>
public const string VulnRead = "vuln:read";
/// <summary>
/// Scope granting read-only access to export center runs and bundles.
/// </summary>
public const string ExportViewer = "export.viewer";
/// <summary>
/// Scope granting permission to operate export center scheduling and run execution.
/// </summary>
public const string ExportOperator = "export.operator";
/// <summary>
/// Scope granting administrative control over export center retention, encryption keys, and scheduling policies.
/// </summary>
public const string ExportAdmin = "export.admin";
/// <summary>
/// Scope granting permission to enqueue or mutate graph build jobs.
/// </summary>
public const string GraphWrite = "graph:write";
/// <summary>
/// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.).
/// </summary>
public const string GraphExport = "graph:export";
/// <summary>
/// Scope granting permission to trigger what-if simulations on graphs.
/// </summary>
public const string GraphSimulate = "graph:simulate";
/// <summary>
/// Scope granting read-only access to Orchestrator job state and telemetry.
/// </summary>
public const string OrchRead = "orch:read";
/// <summary>
/// Scope granting permission to execute Orchestrator control actions.
/// </summary>
public const string OrchOperate = "orch:operate";
/// <summary>
/// Scope granting read-only access to Authority tenant catalog APIs.
/// </summary>
public const string AuthorityTenantsRead = "authority:tenants.read";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
ConcelierMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass,
UiRead,
ExceptionsApprove,
AdvisoryRead,
AdvisoryIngest,
VexRead,
VexIngest,
AocVerify,
SignalsRead,
SignalsWrite,
SignalsAdmin,
PolicyWrite,
PolicyAuthor,
PolicyEdit,
PolicyRead,
PolicyReview,
PolicySubmit,
PolicyApprove,
PolicyOperate,
PolicyAudit,
PolicyRun,
PolicyActivate,
PolicySimulate,
FindingsRead,
EffectiveWrite,
GraphRead,
VulnRead,
ExportViewer,
ExportOperator,
ExportAdmin,
GraphWrite,
GraphExport,
GraphSimulate,
OrchRead,
OrchOperate,
AuthorityTenantsRead
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical identifiers for StellaOps service principals.
/// </summary>
public static class StellaOpsServiceIdentities
{
/// <summary>
/// Service identity used by Policy Engine when materialising effective findings.
/// </summary>
public const string PolicyEngine = "policy-engine";
/// <summary>
/// Service identity used by Cartographer when constructing and maintaining graph projections.
/// </summary>
public const string Cartographer = "cartographer";
/// <summary>
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
/// </summary>
public const string VulnExplorer = "vuln-explorer";
/// <summary>
/// Service identity used by Signals components when managing reachability facts.
/// </summary>
public const string Signals = "signals";
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Shared tenancy default values used across StellaOps services.
/// </summary>
public static class StellaOpsTenancyDefaults
{
/// <summary>
/// Sentinel value indicating the token is not scoped to a specific project.
/// </summary>
public const string AnyProject = "*";
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,134 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsDiscoveryCacheTests
{
[Fact]
public async Task GetAsync_UsesOfflineFallbackWithinTolerance()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var callCount = 0;
var handler = new StubHttpMessageHandler((request, _) =>
{
callCount++;
if (callCount == 1)
{
return Task.FromResult(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
}
throw new HttpRequestException("offline");
});
var httpClient = new HttpClient(handler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
DiscoveryCacheLifetime = TimeSpan.FromMinutes(1),
OfflineCacheTolerance = TimeSpan.FromMinutes(5),
AllowOfflineCacheFallback = true
};
options.Validate();
var monitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var cache = new StellaOpsDiscoveryCache(httpClient, monitor, timeProvider, NullLogger<StellaOpsDiscoveryCache>.Instance);
var configuration = await cache.GetAsync(CancellationToken.None);
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
timeProvider.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(5));
configuration = await cache.GetAsync(CancellationToken.None);
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
Assert.Equal(2, callCount);
var offlineExpiry = GetOfflineExpiry(cache);
Assert.True(offlineExpiry > timeProvider.GetUtcNow());
timeProvider.Advance(options.OfflineCacheTolerance + TimeSpan.FromSeconds(1));
Assert.True(offlineExpiry < timeProvider.GetUtcNow());
HttpRequestException? exception = null;
try
{
await cache.GetAsync(CancellationToken.None);
}
catch (HttpRequestException ex)
{
exception = ex;
}
Assert.NotNull(exception);
Assert.Equal(3, callCount);
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
where T : class
{
private readonly T value;
public TestOptionsMonitor(T value)
{
this.value = value;
}
public T CurrentValue => value;
public T Get(string? name) => value;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
private static DateTimeOffset GetOfflineExpiry(StellaOpsDiscoveryCache cache)
{
var field = typeof(StellaOpsDiscoveryCache).GetField("offlineExpiresAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(field);
return (DateTimeOffset)field!.GetValue(cache)!;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Abstraction for requesting tokens from StellaOps Authority.
/// </summary>
public interface IStellaOpsTokenClient
{
/// <summary>
/// Requests an access token using the resource owner password credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Requests an access token using the client credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the cached JWKS document.
/// </summary>
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a cached token entry.
/// </summary>
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Persists a token entry in the cache.
/// </summary>
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a cached entry.
/// </summary>
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
/// </summary>
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
{
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
private readonly HttpClient httpClient;
private readonly StellaOpsDiscoveryCache discoveryCache;
private readonly StellaOpsJwksCache jwksCache;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly IStellaOpsTokenCache tokenCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsTokenClient>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
public StellaOpsTokenClient(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,
StellaOpsJwksCache jwksCache,
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
IStellaOpsTokenCache tokenCache,
TimeProvider? timeProvider = null,
ILogger<StellaOpsTokenClient>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
string username,
string password,
string? scope = null,
IReadOnlyDictionary<string, string>? additionalParameters = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(username);
ArgumentException.ThrowIfNullOrWhiteSpace(password);
var options = optionsMonitor.CurrentValue;
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "password",
["username"] = username,
["password"] = password,
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
var options = optionsMonitor.CurrentValue;
if (string.IsNullOrWhiteSpace(options.ClientId))
{
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
}
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "client_credentials",
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> jwksCache.GetAsync(cancellationToken);
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.GetAsync(key, cancellationToken);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> tokenCache.SetAsync(key, entry, cancellationToken);
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.RemoveAsync(key, cancellationToken);
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
{
Content = new FormUrlEncodedContent(parameters)
};
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
}
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
{
throw new InvalidOperationException("Token response did not contain an access_token.");
}
var expiresIn = document.ExpiresIn ?? 3600;
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
var result = new StellaOpsTokenResult(
document.AccessToken,
document.TokenType ?? "Bearer",
expiresAt,
normalizedScopes,
document.RefreshToken,
document.IdToken,
payload);
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
return result;
}
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
{
var resolvedScope = scope;
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
{
resolvedScope = string.Join(' ', options.NormalizedScopes);
}
if (!string.IsNullOrWhiteSpace(resolvedScope))
{
parameters["scope"] = resolvedScope;
}
}
private static string[] ParseScopes(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return Array.Empty<string>();
}
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
foreach (var part in parts)
{
unique.Add(part);
}
var result = new string[unique.Count];
unique.CopyTo(result);
Array.Sort(result, StringComparer.Ordinal);
return result;
}
private sealed record TokenResponseDocument(
[property: JsonPropertyName("access_token")] string? AccessToken,
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
[property: JsonPropertyName("id_token")] string? IdToken,
[property: JsonPropertyName("token_type")] string? TokenType,
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
[property: JsonPropertyName("scope")] string? Scope,
[property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("error_description")] string? ErrorDescription);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,199 @@
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.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-alpha")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
[Fact]
public async Task HandleRequirement_Fails_WhenTenantMismatch()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-1")
.WithTenant("tenant-beta")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
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.ConcelierJobsTrigger });
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.ConcelierJobsTrigger });
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Fails_WhenDefaultScopeMissing()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
.WithSubject("user-tenant")
.WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger, StellaOpsScopes.PolicyRun })
.Build();
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
await handler.HandleAsync(context);
Assert.True(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,
optionsMonitor,
NullLogger<StellaOpsScopeAuthorizationHandler>.Instance);
return (handler, accessor);
}
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class, new()
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

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

View File

@@ -0,0 +1,92 @@
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Dependency injection helpers for configuring StellaOps resource server authentication.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Application configuration.</param>
/// <param name="configurationSection">
/// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding.
/// </param>
/// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param>
public static IServiceCollection AddStellaOpsResourceServerAuthentication(
this IServiceCollection services,
IConfiguration configuration,
string? configurationSection = "Authority:ResourceServer",
Action<StellaOpsResourceServerOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddHttpContextAccessor();
services.AddAuthorization();
services.AddStellaOpsScopeHandler();
services.TryAddSingleton<StellaOpsBypassEvaluator>();
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
if (!string.IsNullOrWhiteSpace(configurationSection))
{
optionsBuilder.Bind(configuration.GetSection(configurationSection));
}
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
optionsBuilder.PostConfigure(static options => options.Validate());
var authenticationBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
});
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
{
var resourceOptions = monitor.CurrentValue;
jwt.Authority = resourceOptions.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress))
{
jwt.MetadataAddress = resourceOptions.MetadataAddress;
}
jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata;
jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout;
jwt.MapInboundClaims = false;
jwt.SaveToken = false;
jwt.TokenValidationParameters ??= new TokenValidationParameters();
jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString();
jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0;
jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences;
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
});
return services;
}
}

View File

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

View File

@@ -0,0 +1,116 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
/// </summary>
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
{
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
private readonly IHttpClientFactory httpClientFactory;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private OpenIdConnectConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
public StellaOpsAuthorityConfigurationManager(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<StellaOpsAuthorityConfigurationManager> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var current = Volatile.Read(ref cachedConfiguration);
if (current is not null && now < cacheExpiresAt)
{
return current;
}
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedConfiguration is not null && now < cacheExpiresAt)
{
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options);
var httpClient = httpClientFactory.CreateClient(HttpClientName);
httpClient.Timeout = options.BackchannelTimeout;
var retriever = new HttpDocumentRetriever(httpClient)
{
RequireHttps = options.RequireHttpsMetadata
};
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
{
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys)
{
configuration.SigningKeys.Add(key);
}
}
cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime;
return configuration;
}
finally
{
refreshLock.Release();
}
}
public void RequestRefresh()
{
Volatile.Write(ref cachedConfiguration, null);
cacheExpiresAt = DateTimeOffset.MinValue;
}
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
{
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
{
return options.MetadataAddress;
}
var authority = options.AuthorityUri;
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
{
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
}
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Options controlling StellaOps resource server authentication.
/// </summary>
public sealed class StellaOpsResourceServerOptions
{
private readonly List<string> audiences = new();
private readonly List<string> requiredScopes = new();
private readonly List<string> requiredTenants = new();
private readonly List<string> bypassNetworks = new();
/// <summary>
/// Gets or sets the Authority (issuer) URL that exposes OpenID discovery.
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// Optional explicit OpenID Connect metadata address.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Audiences accepted by the resource server (validated against the <c>aud</c> claim).
/// </summary>
public IList<string> Audiences => audiences;
/// <summary>
/// Scopes enforced by default authorisation policies.
/// </summary>
public IList<string> RequiredScopes => requiredScopes;
/// <summary>
/// Tenants permitted to access the resource server (empty list disables tenant checks).
/// </summary>
public IList<string> RequiredTenants => requiredTenants;
/// <summary>
/// Networks permitted to bypass authentication (used for trusted on-host automation).
/// </summary>
public IList<string> BypassNetworks => bypassNetworks;
/// <summary>
/// Whether HTTPS metadata is required when communicating with Authority.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Back-channel timeout when fetching metadata/JWKS.
/// </summary>
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Clock skew tolerated when validating tokens.
/// </summary>
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
/// </summary>
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets the canonical Authority URI (populated during validation).
/// </summary>
public Uri AuthorityUri { get; private set; } = null!;
/// <summary>
/// Gets the normalised scope list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the normalised tenant list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the network matcher used for bypass checks (populated during validation).
/// </summary>
public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll;
/// <summary>
/// Validates provided configuration and normalises collections.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Resource server authentication requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Resource server Authority URL must be an absolute URI.");
}
if (RequireHttpsMetadata &&
!authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
}
if (BackchannelTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero.");
}
if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
}
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
{
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
}
AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false);
NormalizeList(requiredScopes, toLower: true);
NormalizeList(requiredTenants, toLower: true);
NormalizeList(bypassNetworks, toLower: false);
NormalizedScopes = requiredScopes.Count == 0
? Array.Empty<string>()
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
NormalizedTenants = requiredTenants.Count == 0
? Array.Empty<string>()
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
BypassMatcher = bypassNetworks.Count == 0
? NetworkMaskMatcher.DenyAll
: new NetworkMaskMatcher(bypassNetworks);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var trimmed = value.Trim();
if (toLower)
{
trimmed = trimmed.ToLowerInvariant();
}
if (!seen.Add(trimmed))
{
values.RemoveAt(index);
continue;
}
values[index] = trimmed;
}
}
}

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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 IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly ILogger<StellaOpsScopeAuthorizationHandler> logger;
public StellaOpsScopeAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
StellaOpsBypassEvaluator bypassEvaluator,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
ILogger<StellaOpsScopeAuthorizationHandler> logger)
{
this.httpContextAccessor = httpContextAccessor;
this.bypassEvaluator = bypassEvaluator;
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.logger = logger;
}
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
StellaOpsScopeRequirement requirement)
{
var resourceOptions = optionsMonitor.CurrentValue;
var httpContext = httpContextAccessor.HttpContext;
var combinedScopes = CombineRequiredScopes(resourceOptions.NormalizedScopes, requirement.RequiredScopes);
HashSet<string>? userScopes = null;
if (context.User?.Identity?.IsAuthenticated == true)
{
userScopes = ExtractScopes(context.User);
foreach (var scope in combinedScopes)
{
if (!userScopes.Contains(scope))
{
continue;
}
if (TenantAllowed(context.User, resourceOptions, out var normalizedTenant))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (logger.IsEnabled(LogLevel.Debug))
{
var allowedTenants = resourceOptions.NormalizedTenants.Count == 0
? "(none)"
: string.Join(", ", resourceOptions.NormalizedTenants);
logger.LogDebug(
"Tenant requirement not satisfied. RequiredTenants={RequiredTenants}; PrincipalTenant={PrincipalTenant}; Remote={Remote}",
allowedTenants,
normalizedTenant ?? "(none)",
httpContext?.Connection.RemoteIpAddress);
}
// tenant mismatch cannot be resolved by checking additional scopes for this principal
break;
}
}
if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, combinedScopes))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (logger.IsEnabled(LogLevel.Debug))
{
var required = string.Join(", ", combinedScopes);
var principalScopes = userScopes is null || userScopes.Count == 0
? "(none)"
: string.Join(", ", userScopes);
var tenantValue = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? "(none)";
logger.LogDebug(
"Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Tenant={Tenant}; Remote={Remote}",
required,
principalScopes,
tenantValue,
httpContext?.Connection.RemoteIpAddress);
}
return Task.CompletedTask;
}
private static bool TenantAllowed(ClaimsPrincipal principal, StellaOpsResourceServerOptions options, out string? normalizedTenant)
{
normalizedTenant = null;
if (options.NormalizedTenants.Count == 0)
{
return true;
}
var rawTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(rawTenant))
{
return false;
}
normalizedTenant = rawTenant.Trim().ToLowerInvariant();
foreach (var allowed in options.NormalizedTenants)
{
if (string.Equals(allowed, normalizedTenant, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
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;
}
private static IReadOnlyList<string> CombineRequiredScopes(
IReadOnlyList<string> defaultScopes,
IReadOnlyCollection<string> requirementScopes)
{
if ((defaultScopes is null || defaultScopes.Count == 0) && (requirementScopes is null || requirementScopes.Count == 0))
{
return Array.Empty<string>();
}
if (defaultScopes is null || defaultScopes.Count == 0)
{
return requirementScopes is string[] requirementArray
? requirementArray
: requirementScopes.ToArray();
}
var combined = new HashSet<string>(defaultScopes, StringComparer.Ordinal);
if (requirementScopes is not null)
{
foreach (var scope in requirementScopes)
{
if (!string.IsNullOrWhiteSpace(scope))
{
combined.Add(scope);
}
}
}
return combined.Count == defaultScopes.Count && requirementScopes is null
? defaultScopes
: combined.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
}

View File

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

View File

@@ -0,0 +1,125 @@
using System;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Cryptography;
namespace StellaOps.Authority.Plugin.Standard.Tests.Security;
public class CryptoPasswordHasherTests
{
[Fact]
public void Hash_EmitsArgon2idByDefault()
{
var options = CreateOptions();
var hasher = new CryptoPasswordHasher(options, new DefaultCryptoProvider());
var encoded = hasher.Hash("Secr3t!");
Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal);
}
[Fact]
public void Verify_ReturnsSuccess_ForCurrentAlgorithm()
{
var options = CreateOptions();
var provider = new DefaultCryptoProvider();
var hasher = new CryptoPasswordHasher(options, provider);
var encoded = hasher.Hash("Passw0rd!");
var result = hasher.Verify("Passw0rd!", encoded);
Assert.Equal(PasswordVerificationResult.Success, result);
}
[Fact]
public void Verify_FlagsLegacyPbkdf2_ForRehash()
{
var options = CreateOptions();
var provider = new DefaultCryptoProvider();
var hasher = new CryptoPasswordHasher(options, provider);
var legacy = new Pbkdf2PasswordHasher().Hash(
"Passw0rd!",
new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 150_000
});
var result = hasher.Verify("Passw0rd!", legacy);
Assert.Equal(PasswordVerificationResult.SuccessRehashNeeded, result);
}
[Fact]
public void Verify_RejectsTamperedPayload()
{
var options = CreateOptions();
var provider = new DefaultCryptoProvider();
var hasher = new CryptoPasswordHasher(options, provider);
var legacy = new Pbkdf2PasswordHasher().Hash(
"Passw0rd!",
new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 160_000
});
var tampered = legacy + "corrupted";
var result = hasher.Verify("Passw0rd!", tampered);
Assert.Equal(PasswordVerificationResult.Failed, result);
}
[Fact]
public void Verify_AllowsLegacyAlgorithmWhenConfigured()
{
var options = CreateOptions();
options.PasswordHashing = options.PasswordHashing with
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 200_000
};
var provider = new DefaultCryptoProvider();
var hasher = new CryptoPasswordHasher(options, provider);
var legacy = new Pbkdf2PasswordHasher().Hash(
"Passw0rd!",
new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 200_000
});
var result = hasher.Verify("Passw0rd!", legacy);
Assert.Equal(PasswordVerificationResult.Success, result);
}
private static StandardPluginOptions CreateOptions() => new()
{
PasswordPolicy = new PasswordPolicyOptions
{
MinimumLength = 8,
RequireDigit = true,
RequireLowercase = true,
RequireUppercase = true,
RequireSymbol = false
},
Lockout = new LockoutOptions
{
Enabled = true,
MaxAttempts = 5,
WindowMinutes = 15
},
PasswordHashing = new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Argon2id,
MemorySizeInKib = 8 * 1024,
Iterations = 2,
Parallelism = 1
}
};
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using Xunit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardClientProvisioningStoreTests
{
[Fact]
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "bootstrap-client",
confidential: true,
displayName: "Bootstrap",
clientSecret: "SuperSecret1!",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
Assert.NotNull(document);
Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
Assert.Equal("standard", document.Plugin);
var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("bootstrap-client", descriptor!.ClientId);
Assert.True(descriptor.Confidential);
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
Assert.Contains("scopea", descriptor.AllowedScopes);
}
[Fact]
[Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "tenant-client",
confidential: false,
displayName: "Tenant Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" },
tenant: " Tenant-Alpha " );
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("tenant-alpha", descriptor!.Tenant);
}
public async Task CreateOrUpdateAsync_StoresAudiences()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "signer",
confidential: false,
displayName: "Signer",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "attestor", "signer" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("signer", out var document));
Assert.NotNull(document);
Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]);
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal));
}
[Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
thumbprint: "aa:bb:cc:dd",
serialNumber: "01ff",
subject: "CN=mtls-client",
issuer: "CN=test-ca",
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
notAfter: DateTimeOffset.UtcNow.AddHours(1),
label: "primary");
var registration = new AuthorityClientRegistration(
clientId: "mtls-client",
confidential: true,
displayName: "MTLS Client",
clientSecret: "secret",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "signer" },
certificateBindings: new[] { bindingRegistration });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("mtls-client", out var document));
Assert.NotNull(document);
var binding = Assert.Single(document!.CertificateBindings);
Assert.Equal("AABBCCDD", binding.Thumbprint);
Assert.Equal("01ff", binding.SerialNumber);
Assert.Equal("CN=mtls-client", binding.Subject);
Assert.Equal("CN=test-ca", binding.Issuer);
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
Assert.Equal("primary", binding.Label);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
{
public List<AuthorityRevocationDocument> Upserts { get; } = new();
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Upserts.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(true);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
}

View File

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

View File

@@ -0,0 +1,354 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginRegistrarTests
{
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "8",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false",
["lockout:enabled"] = "false",
["passwordHashing:memorySizeInKib"] = "8192",
["passwordHashing:iterations"] = "2",
["passwordHashing:parallelism"] = "1",
["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());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton(TimeProvider.System);
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>();
foreach (var hosted in hostedServices)
{
if (hosted is StandardPluginBootstrapper bootstrapper)
{
await bootstrapper.StartAsync(CancellationToken.None);
}
}
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.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_LogsWarning_WhenPasswordPolicyWeaker()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-password-policy");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "6",
["passwordPolicy:requireUppercase"] = "false",
["passwordPolicy:requireLowercase"] = "false",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false"
})
.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();
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
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();
using var scope = provider.CreateScope();
_ = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
Assert.Contains(loggerProvider.Entries, entry =>
entry.Level == LogLevel.Warning &&
entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
}
[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());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.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();
using var scope = provider.CreateScope();
Assert.Throws<InvalidOperationException>(() => scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>());
}
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["tokenSigning:keyDirectory"] = "../keys"
})
.Build();
var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(configDir);
try
{
var configPath = Path.Combine(configDir, "standard.yaml");
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
}
finally
{
if (Directory.Exists(configDir))
{
Directory.Delete(configDir, recursive: true);
}
}
}
}
internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message);
internal sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<CapturedLogEntry> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly string category;
private readonly List<CapturedLogEntry> entries;
public CapturingLogger(string category, List<CapturedLogEntry> entries)
{
this.category = category;
this.entries = entries;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose()
{
}
}
}
}
internal sealed class StubRevocationStore : IAuthorityRevocationStore
{
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Linq;
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;
using StellaOps.Cryptography;
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
},
PasswordHashing = new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Argon2id,
MemorySizeInKib = 8 * 1024,
Iterations = 2,
Parallelism = 1
}
};
var cryptoProvider = new DefaultCryptoProvider();
store = new StandardUserCredentialStore(
"standard",
database,
options,
new CryptoPasswordHasher(options, cryptoProvider),
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);
Assert.Empty(result.AuditProperties);
}
[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);
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
}
[Fact]
public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
{
var legacyHash = new Pbkdf2PasswordHasher().Hash(
"Legacy1!",
new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 160_000
});
var document = new StandardUserDocument
{
Username = "legacy",
NormalizedUsername = "legacy",
PasswordHash = legacyHash,
Roles = new List<string>(),
Attributes = new Dictionary<string, string?>(),
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
};
await database.GetCollection<StandardUserDocument>("authority_users_standard")
.InsertOneAsync(document);
var result = await store.VerifyPasswordAsync("legacy", "Legacy1!", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("legacy", result.User?.Username);
Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed");
var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
.Find(u => u.NormalizedUsername == "legacy")
.FirstOrDefaultAsync();
Assert.NotNull(updated);
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,15 @@
<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>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,44 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Standard.Storage;
namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
internal sealed class StandardPluginBootstrapper : IHostedService
{
private readonly string pluginName;
private readonly IServiceScopeFactory scopeFactory;
private readonly ILogger<StandardPluginBootstrapper> logger;
public StandardPluginBootstrapper(
string pluginName,
IServiceScopeFactory scopeFactory,
ILogger<StandardPluginBootstrapper> logger)
{
this.pluginName = pluginName;
this.scopeFactory = scopeFactory;
this.logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
var options = optionsMonitor.Get(pluginName);
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
{
return;
}
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

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

View File

@@ -0,0 +1,86 @@
using System;
using StellaOps.Cryptography;
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 CryptoPasswordHasher : IPasswordHasher
{
private readonly StandardPluginOptions options;
private readonly ICryptoProvider cryptoProvider;
public CryptoPasswordHasher(StandardPluginOptions options, ICryptoProvider cryptoProvider)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider));
}
public string Hash(string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
var hashOptions = options.PasswordHashing;
hashOptions.Validate();
var hasher = cryptoProvider.GetPasswordHasher(hashOptions.Algorithm.ToAlgorithmId());
return hasher.Hash(password, hashOptions);
}
public PasswordVerificationResult Verify(string password, string hashedPassword)
{
ArgumentException.ThrowIfNullOrEmpty(password);
ArgumentException.ThrowIfNullOrEmpty(hashedPassword);
var desired = options.PasswordHashing;
desired.Validate();
var primaryHasher = cryptoProvider.GetPasswordHasher(desired.Algorithm.ToAlgorithmId());
if (IsArgon2Hash(hashedPassword))
{
if (!primaryHasher.Verify(password, hashedPassword))
{
return PasswordVerificationResult.Failed;
}
return primaryHasher.NeedsRehash(hashedPassword, desired)
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Success;
}
if (IsLegacyPbkdf2Hash(hashedPassword))
{
var legacyHasher = cryptoProvider.GetPasswordHasher(PasswordHashAlgorithm.Pbkdf2.ToAlgorithmId());
if (!legacyHasher.Verify(password, hashedPassword))
{
return PasswordVerificationResult.Failed;
}
return desired.Algorithm == PasswordHashAlgorithm.Pbkdf2 &&
!legacyHasher.NeedsRehash(hashedPassword, desired)
? PasswordVerificationResult.Success
: PasswordVerificationResult.SuccessRehashNeeded;
}
return PasswordVerificationResult.Failed;
}
private static bool IsArgon2Hash(string value) =>
value.StartsWith("$argon2id$", StringComparison.Ordinal);
private static bool IsLegacyPbkdf2Hash(string value) =>
value.StartsWith("PBKDF2.", StringComparison.Ordinal);
}

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
using System;
using System.IO;
using StellaOps.Cryptography;
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 PasswordHashOptions PasswordHashing { get; set; } = new();
public void Normalize(string configPath)
{
TokenSigning.Normalize(configPath);
}
public void Validate(string pluginName)
{
BootstrapUser?.Validate(pluginName);
PasswordPolicy.Validate(pluginName);
Lockout.Validate(pluginName);
PasswordHashing.Validate();
}
}
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.");
}
}
public bool IsWeakerThan(PasswordPolicyOptions other)
{
if (other is null)
{
return false;
}
if (MinimumLength < other.MinimumLength)
{
return true;
}
if (!RequireUppercase && other.RequireUppercase)
{
return true;
}
if (!RequireLowercase && other.RequireLowercase)
{
return true;
}
if (!RequireDigit && other.RequireDigit)
{
return true;
}
if (!RequireSymbol && other.RequireSymbol)
{
return true;
}
return false;
}
}
internal sealed class LockoutOptions
{
public bool Enabled { get; set; } = true;
public int MaxAttempts { get; set; } = 5;
public int WindowMinutes { get; set; } = 15;
public TimeSpan Window => TimeSpan.FromMinutes(WindowMinutes <= 0 ? 15 : WindowMinutes);
public void Validate(string pluginName)
{
if (Enabled && MaxAttempts <= 0)
{
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires lockout.maxAttempts to be greater than zero when lockout is enabled.");
}
if (Enabled && WindowMinutes <= 0)
{
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires lockout.windowMinutes to be greater than zero when lockout is enabled.");
}
}
}
internal sealed class TokenSigningOptions
{
public string? KeyDirectory { get; set; }
public void Normalize(string configPath)
{
if (string.IsNullOrWhiteSpace(KeyDirectory))
{
KeyDirectory = null;
return;
}
var resolved = KeyDirectory.Trim();
if (string.IsNullOrEmpty(resolved))
{
KeyDirectory = null;
return;
}
resolved = Environment.ExpandEnvironmentVariables(resolved);
if (!Path.IsPathRooted(resolved))
{
var baseDirectory = Path.GetDirectoryName(configPath);
if (string.IsNullOrEmpty(baseDirectory))
{
baseDirectory = Directory.GetCurrentDirectory();
}
resolved = Path.Combine(baseDirectory, resolved);
}
KeyDirectory = Path.GetFullPath(resolved);
}
}

View File

@@ -0,0 +1,112 @@
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;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
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.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
.PostConfigure(options =>
{
options.Normalize(configPath);
options.Validate(pluginName);
})
.ValidateOnStart();
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{
registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
pluginName,
pluginOptions.PasswordPolicy.MinimumLength,
pluginOptions.PasswordPolicy.RequireUppercase,
pluginOptions.PasswordPolicy.RequireLowercase,
pluginOptions.PasswordPolicy.RequireDigit,
pluginOptions.PasswordPolicy.RequireSymbol,
baselinePolicy.MinimumLength,
baselinePolicy.RequireUppercase,
baselinePolicy.RequireLowercase,
baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol);
}
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp =>
{
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
});
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
{
var store = sp.GetRequiredService<StandardUserCredentialStore>();
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
return new StandardIdentityProviderPlugin(
context.Plugin,
store,
clientProvisioningStore,
sp.GetRequiredService<StandardClaimsEnricher>(),
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
});
context.Services.AddScoped<IClientProvisioningStore>(sp =>
sp.GetRequiredService<StandardClientProvisioningStore>());
context.Services.AddSingleton<IHostedService>(sp =>
new StandardPluginBootstrapper(
pluginName,
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
}
}

View File

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<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="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="MongoDB.Driver" Version="3.5.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="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,248 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
{
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly TimeProvider clock;
public StandardClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
TimeProvider clock)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
if (registration.CertificateBindings is not null)
{
var now = clock.GetUtcNow();
document.CertificateBindings = registration.CertificateBindings
.Select(binding => MapCertificateBinding(binding, now))
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
.ToList();
}
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
var normalizedTenant = NormalizeTenant(registration.Tenant);
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (normalizedConstraint is not null)
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return document is null ? null : ToDescriptor(document);
}
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if the metadata write fails.
}
return AuthorityPluginOperationResult.Success();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
audiences,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
{
return string.Empty;
}
return string.Join(
" ",
values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal));
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()
: registration.SubjectAlternativeNames
.Select(name => name.Trim())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AuthorityClientCertificateBinding
{
Thumbprint = registration.Thumbprint,
SerialNumber = registration.SerialNumber,
Subject = registration.Subject,
Issuer = registration.Issuer,
SubjectAlternativeNames = subjectAlternativeNames,
NotBefore = registration.NotBefore,
NotAfter = registration.NotAfter,
Label = registration.Label,
CreatedAt = now,
UpdatedAt = now
};
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim() switch
{
{ Length: 0 } => null,
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
_ => null
};
}
}

View File

@@ -0,0 +1,374 @@
using System;
using System.Collections.Generic;
using System.Globalization;
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;
using StellaOps.Cryptography.Audit;
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)
{
var auditProperties = new List<AuthEventProperty>();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
{
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
}
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, auditProperties: auditProperties);
}
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);
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.lockout_until",
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
});
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.LockedOut,
"Account is temporarily locked.",
retryAfter,
auditProperties);
}
var verification = passwordHasher.Verify(password, user.PasswordHash);
if (verification is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded)
{
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = passwordHasher.Hash(password);
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.rehashed",
Value = ClassifiedString.Public("argon2id")
});
}
var previousFailures = user.Lockout.FailedAttempts;
ResetLockout(user);
user.UpdatedAt = DateTimeOffset.UtcNow;
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (previousFailures > 0)
{
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.failed_attempts_cleared",
Value = ClassifiedString.Public(previousFailures.ToString(CultureInfo.InvariantCulture))
});
}
var descriptor = ToDescriptor(user);
return AuthorityCredentialVerificationResult.Success(
descriptor,
descriptor.RequiresPasswordReset ? "Password reset required." : null,
auditProperties);
}
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;
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.failed_attempts",
Value = ClassifiedString.Public(user.Lockout.FailedAttempts.ToString(CultureInfo.InvariantCulture))
});
if (user.Lockout.LockoutEnd is { } pendingLockout)
{
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.lockout_until",
Value = ClassifiedString.Public(pendingLockout.ToString("O", CultureInfo.InvariantCulture))
});
}
return AuthorityCredentialVerificationResult.Failure(
code,
code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
retry,
auditProperties);
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
AuthorityUserRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
var normalized = NormalizeUsername(registration.Username);
var now = DateTimeOffset.UtcNow;
if (!string.IsNullOrEmpty(registration.Password))
{
var passwordValidation = ValidatePassword(registration.Password);
if (passwordValidation is not null)
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_policy_violation", passwordValidation);
}
}
var existing = await users.Find(u => u.NormalizedUsername == normalized)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
if (string.IsNullOrEmpty(registration.Password))
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_required", "New users require a password.");
}
var document = new StandardUserDocument
{
Username = registration.Username,
NormalizedUsername = normalized,
DisplayName = registration.DisplayName,
Email = registration.Email,
PasswordHash = passwordHasher.Hash(registration.Password!),
RequirePasswordReset = registration.RequirePasswordReset,
Roles = registration.Roles.ToList(),
Attributes = new Dictionary<string, string?>(registration.Attributes, StringComparer.OrdinalIgnoreCase),
CreatedAt = now,
UpdatedAt = now
};
await users.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(document));
}
existing.Username = registration.Username;
existing.DisplayName = registration.DisplayName ?? existing.DisplayName;
existing.Email = registration.Email ?? existing.Email;
existing.Roles = registration.Roles.Any()
? registration.Roles.ToList()
: existing.Roles;
if (registration.Attributes.Count > 0)
{
foreach (var pair in registration.Attributes)
{
existing.Attributes[pair.Key] = pair.Value;
}
}
if (!string.IsNullOrEmpty(registration.Password))
{
existing.PasswordHash = passwordHasher.Hash(registration.Password!);
existing.RequirePasswordReset = registration.RequirePasswordReset;
}
else if (registration.RequirePasswordReset)
{
existing.RequirePasswordReset = true;
}
existing.UpdatedAt = now;
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, existing.Id),
existing,
cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(existing));
}
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return null;
}
var user = await users.Find(u => u.SubjectId == subjectId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return user is null ? null : ToDescriptor(user);
}
public async Task EnsureBootstrapUserAsync(BootstrapUserOptions bootstrap, CancellationToken cancellationToken)
{
if (bootstrap is null || !bootstrap.IsConfigured)
{
return;
}
var registration = new AuthorityUserRegistration(
bootstrap.Username!,
bootstrap.Password,
displayName: bootstrap.Username,
email: null,
requirePasswordReset: bootstrap.RequirePasswordReset,
roles: Array.Empty<string>(),
attributes: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
var result = await UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
if (!result.Succeeded)
{
logger.LogWarning(
"Plugin {PluginName} failed to seed bootstrap user '{Username}': {Reason}",
pluginName,
bootstrap.Username,
result.ErrorCode);
}
}
public async Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
{
try
{
var command = new BsonDocument("ping", 1);
await users.Database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginHealthResult.Healthy();
}
catch (Exception ex)
{
logger.LogError(ex, "Plugin {PluginName} failed MongoDB health check.", pluginName);
return AuthorityPluginHealthResult.Unavailable(ex.Message);
}
}
private string? ValidatePassword(string password)
{
if (password.Length < options.PasswordPolicy.MinimumLength)
{
return $"Password must be at least {options.PasswordPolicy.MinimumLength} characters long.";
}
if (options.PasswordPolicy.RequireUppercase && !password.Any(char.IsUpper))
{
return "Password must contain an uppercase letter.";
}
if (options.PasswordPolicy.RequireLowercase && !password.Any(char.IsLower))
{
return "Password must contain a lowercase letter.";
}
if (options.PasswordPolicy.RequireDigit && !password.Any(char.IsDigit))
{
return "Password must contain a digit.";
}
if (options.PasswordPolicy.RequireSymbol && password.All(char.IsLetterOrDigit))
{
return "Password must contain a symbol.";
}
return null;
}
private async Task RegisterFailureAsync(StandardUserDocument user, CancellationToken cancellationToken)
{
user.Lockout.LastFailure = DateTimeOffset.UtcNow;
user.Lockout.FailedAttempts += 1;
if (options.Lockout.Enabled && user.Lockout.FailedAttempts >= options.Lockout.MaxAttempts)
{
user.Lockout.LockoutEnd = DateTimeOffset.UtcNow + options.Lockout.Window;
user.Lockout.FailedAttempts = 0;
}
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static void ResetLockout(StandardUserDocument user)
{
user.Lockout.FailedAttempts = 0;
user.Lockout.LockoutEnd = null;
user.Lockout.LastFailure = null;
}
private static string NormalizeUsername(string username)
=> username.Trim().ToLowerInvariant();
private AuthorityUserDescriptor ToDescriptor(StandardUserDocument document)
=> new(
document.SubjectId,
document.Username,
document.DisplayName,
document.RequirePasswordReset,
document.Roles,
document.Attributes);
private void EnsureIndexes()
{
var indexKeys = Builders<StandardUserDocument>.IndexKeys
.Ascending(u => u.NormalizedUsername);
var indexModel = new CreateIndexModel<StandardUserDocument>(
indexKeys,
new CreateIndexOptions { Unique = true, Name = "idx_normalized_username" });
try
{
users.Indexes.CreateOne(indexModel);
}
catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityClientRegistrationTests
{
[Fact]
public void Constructor_Throws_WhenClientIdMissing()
{
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration(string.Empty, false, null, null));
}
[Fact]
public void Constructor_RequiresSecret_ForConfidentialClients()
{
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration("cli", true, null, null));
}
[Fact]
public void WithClientSecret_ReturnsCopy()
{
var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha");
var updated = registration.WithClientSecret("secret");
Assert.Equal("cli", updated.ClientId);
Assert.Equal("secret", updated.ClientSecret);
Assert.False(updated.Confidential);
Assert.Equal("tenant-alpha", updated.Tenant);
}
}

View File

@@ -0,0 +1,51 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityCredentialVerificationResultTests
{
[Fact]
public void Success_SetsUserAndClearsFailure()
{
var user = new AuthorityUserDescriptor("subject-1", "user", "User", false);
var auditProperties = new[]
{
new AuthEventProperty { Name = "test", Value = ClassifiedString.Public("value") }
};
var result = AuthorityCredentialVerificationResult.Success(user, "ok", auditProperties);
Assert.True(result.Succeeded);
Assert.Equal(user, result.User);
Assert.Null(result.FailureCode);
Assert.Equal("ok", result.Message);
Assert.Collection(result.AuditProperties, property => Assert.Equal("test", property.Name));
}
[Fact]
public void Success_Throws_WhenUserNull()
{
Assert.Throws<ArgumentNullException>(() => AuthorityCredentialVerificationResult.Success(null!));
}
[Fact]
public void Failure_SetsFailureCode()
{
var auditProperties = new[]
{
new AuthEventProperty { Name = "reason", Value = ClassifiedString.Public("lockout") }
};
var result = AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.LockedOut, "locked", TimeSpan.FromMinutes(5), auditProperties);
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);
Assert.Collection(result.AuditProperties, property => Assert.Equal("reason", property.Name));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
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 Audiences = "audiences";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
public const string Tenant = "tenant";
public const string Project = "project";
public const string ServiceIdentity = "serviceIdentity";
}

View File

@@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known Authority plugin capability identifiers.
/// </summary>
public static class AuthorityPluginCapabilities
{
public const string Password = "password";
public const string Bootstrap = "bootstrap";
public const string Mfa = "mfa";
public const string ClientProvisioning = "clientProvisioning";
}
/// <summary>
/// Immutable description of an Authority plugin loaded from configuration.
/// </summary>
/// <param name="Name">Logical name derived from configuration key.</param>
/// <param name="Type">Plugin type identifier (used for capability routing).</param>
/// <param name="Enabled">Whether the plugin is enabled.</param>
/// <param name="AssemblyName">Assembly name without extension.</param>
/// <param name="AssemblyPath">Explicit assembly path override.</param>
/// <param name="Capabilities">Capability hints exposed by the plugin.</param>
/// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param>
/// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param>
public sealed record AuthorityPluginManifest(
string Name,
string Type,
bool Enabled,
string? AssemblyName,
string? AssemblyPath,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string?> Metadata,
string ConfigPath)
{
/// <summary>
/// Determines whether the manifest declares the specified capability.
/// </summary>
/// <param name="capability">Capability identifier to check.</param>
public bool HasCapability(string capability)
{
if (string.IsNullOrWhiteSpace(capability))
{
return false;
}
foreach (var entry in Capabilities)
{
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Runtime context combining plugin manifest metadata and its bound configuration.
/// </summary>
/// <param name="Manifest">Manifest describing the plugin.</param>
/// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param>
public sealed record AuthorityPluginContext(
AuthorityPluginManifest Manifest,
IConfiguration Configuration);
/// <summary>
/// Registry exposing the set of Authority plugins loaded at runtime.
/// </summary>
public interface IAuthorityPluginRegistry
{
IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context);
AuthorityPluginContext GetRequired(string name)
{
if (TryGet(name, out var context))
{
return context;
}
throw new KeyNotFoundException($"Authority plugin '{name}' is not registered.");
}
}
/// <summary>
/// Registry exposing loaded identity provider plugins and their capabilities.
/// </summary>
public interface IAuthorityIdentityProviderRegistry
{
/// <summary>
/// Gets metadata for all registered identity provider plugins.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
/// <summary>
/// Gets metadata for identity providers that advertise password support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
/// <summary>
/// Gets metadata for identity providers that advertise multi-factor authentication support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
/// <summary>
/// Gets metadata for identity providers that advertise client provisioning support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders { get; }
/// <summary>
/// Aggregate capability flags across all registered providers.
/// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary>
/// Attempts to resolve identity provider metadata by name.
/// </summary>
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
/// <summary>
/// Resolves identity provider metadata by name or throws when not found.
/// </summary>
AuthorityIdentityProviderMetadata GetRequired(string name)
{
if (TryGet(name, out var metadata))
{
return metadata;
}
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
/// <summary>
/// Acquires a scoped handle to the specified identity provider.
/// </summary>
/// <param name="name">Logical provider name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Handle managing the provider instance lifetime.</returns>
ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken);
}
/// <summary>
/// Immutable metadata describing a registered identity provider.
/// </summary>
/// <param name="Name">Logical provider name from the manifest.</param>
/// <param name="Type">Provider type identifier.</param>
/// <param name="Capabilities">Capability flags advertised by the provider.</param>
public sealed record AuthorityIdentityProviderMetadata(
string Name,
string Type,
AuthorityIdentityProviderCapabilities Capabilities);
/// <summary>
/// Represents a scoped identity provider instance and manages its disposal.
/// </summary>
public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable
{
private readonly AsyncServiceScope scope;
private bool disposed;
public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider)
{
this.scope = scope;
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Provider = provider ?? throw new ArgumentNullException(nameof(provider));
}
/// <summary>
/// Gets the metadata associated with the provider instance.
/// </summary>
public AuthorityIdentityProviderMetadata Metadata { get; }
/// <summary>
/// Gets the active provider instance.
/// </summary>
public IIdentityProviderPlugin Provider { get; }
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
scope.Dispose();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await scope.DisposeAsync().ConfigureAwait(false);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,897 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography.Audit;
using StellaOps.Auth.Abstractions;
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,
IReadOnlyList<AuthEventProperty> auditProperties)
{
Succeeded = succeeded;
User = user;
FailureCode = failureCode;
Message = message;
RetryAfter = retryAfter;
AuditProperties = auditProperties ?? Array.Empty<AuthEventProperty>();
}
/// <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>
/// Additional audit properties emitted by the credential store.
/// </summary>
public IReadOnlyList<AuthEventProperty> AuditProperties { get; }
/// <summary>
/// Builds a successful verification result.
/// </summary>
public static AuthorityCredentialVerificationResult Success(
AuthorityUserDescriptor user,
string? message = null,
IReadOnlyList<AuthEventProperty>? auditProperties = null)
=> new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null, auditProperties ?? Array.Empty<AuthEventProperty>());
/// <summary>
/// Builds a failed verification result.
/// </summary>
public static AuthorityCredentialVerificationResult Failure(
AuthorityCredentialFailureCode failureCode,
string? message = null,
TimeSpan? retryAfter = null,
IReadOnlyList<AuthEventProperty>? auditProperties = null)
=> new(false, null, failureCode, message, retryAfter, auditProperties ?? Array.Empty<AuthEventProperty>());
}
/// <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
{
public AuthorityClientDescriptor(
string clientId,
string? displayName,
bool confidential,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<string>? allowedAudiences = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
DisplayName = displayName;
Confidential = confidential;
AllowedGrantTypes = Normalize(allowedGrantTypes);
AllowedScopes = NormalizeScopes(allowedScopes);
AllowedAudiences = Normalize(allowedAudiences);
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
var propertyBag = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
Tenant = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
? AuthorityClientRegistration.NormalizeTenantValue(tenantValue)
: null;
var normalizedProject = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue)
? AuthorityClientRegistration.NormalizeProjectValue(projectValue)
: null;
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
Properties = propertyBag;
}
public string ClientId { get; }
public string? DisplayName { get; }
public bool Confidential { get; }
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
public IReadOnlyCollection<string> AllowedScopes { get; }
public IReadOnlyCollection<string> AllowedAudiences { get; }
public IReadOnlyCollection<Uri> RedirectUris { get; }
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
public string? Tenant { get; }
public string? Project { get; }
public IReadOnlyDictionary<string, string?> Properties { get; }
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
=> values is null || values.Count == 0
? Array.Empty<string>()
: values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
private static IReadOnlyCollection<string> NormalizeScopes(IReadOnlyCollection<string>? values)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(StringComparer.Ordinal);
foreach (var value in values)
{
var normalized = StellaOpsScopes.Normalize(value);
if (normalized is null)
{
continue;
}
unique.Add(normalized);
}
if (unique.Count == 0)
{
return Array.Empty<string>();
}
return unique.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}
public sealed record AuthorityClientCertificateBindingRegistration
{
public AuthorityClientCertificateBindingRegistration(
string thumbprint,
string? serialNumber = null,
string? subject = null,
string? issuer = null,
IReadOnlyCollection<string>? subjectAlternativeNames = null,
DateTimeOffset? notBefore = null,
DateTimeOffset? notAfter = null,
string? label = null)
{
Thumbprint = NormalizeThumbprint(thumbprint);
SerialNumber = Normalize(serialNumber);
Subject = Normalize(subject);
Issuer = Normalize(issuer);
SubjectAlternativeNames = subjectAlternativeNames is null || subjectAlternativeNames.Count == 0
? Array.Empty<string>()
: subjectAlternativeNames
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
NotBefore = notBefore;
NotAfter = notAfter;
Label = Normalize(label);
}
public string Thumbprint { get; }
public string? SerialNumber { get; }
public string? Subject { get; }
public string? Issuer { get; }
public IReadOnlyCollection<string> SubjectAlternativeNames { get; }
public DateTimeOffset? NotBefore { get; }
public DateTimeOffset? NotAfter { get; }
public string? Label { get; }
private static string NormalizeThumbprint(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Thumbprint is required.", nameof(value));
}
return value
.Replace(":", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal)
.ToUpperInvariant();
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record AuthorityClientRegistration
{
public AuthorityClientRegistration(
string clientId,
bool confidential,
string? displayName,
string? clientSecret,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<string>? allowedAudiences = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
string? tenant = null,
string? project = null,
IReadOnlyDictionary<string, string?>? properties = null,
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
Confidential = confidential;
DisplayName = displayName;
ClientSecret = confidential
? ValidateRequired(clientSecret ?? string.Empty, nameof(clientSecret))
: clientSecret;
AllowedGrantTypes = Normalize(allowedGrantTypes);
AllowedScopes = NormalizeScopes(allowedScopes);
AllowedAudiences = Normalize(allowedAudiences);
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Tenant = NormalizeTenantValue(tenant);
var propertyBag = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
var normalizedProject = NormalizeProjectValue(project ?? (propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue) ? projectValue : null));
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
Properties = propertyBag;
CertificateBindings = certificateBindings is null
? Array.Empty<AuthorityClientCertificateBindingRegistration>()
: certificateBindings.ToArray();
}
public string ClientId { get; }
public bool Confidential { get; }
public string? DisplayName { get; }
public string? ClientSecret { get; init; }
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
public IReadOnlyCollection<string> AllowedScopes { get; }
public IReadOnlyCollection<string> AllowedAudiences { get; }
public IReadOnlyCollection<Uri> RedirectUris { get; }
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
public string? Tenant { get; }
public string? Project { get; }
public IReadOnlyDictionary<string, string?> Properties { get; }
public IReadOnlyCollection<AuthorityClientCertificateBindingRegistration> CertificateBindings { get; }
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Tenant, Project, Properties, CertificateBindings);
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
=> values is null || values.Count == 0
? Array.Empty<string>()
: values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
private static IReadOnlyCollection<string> NormalizeScopes(IReadOnlyCollection<string>? values)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(StringComparer.Ordinal);
foreach (var value in values)
{
var normalized = StellaOpsScopes.Normalize(value);
if (normalized is null)
{
continue;
}
unique.Add(normalized);
}
if (unique.Count == 0)
{
return Array.Empty<string>();
}
return unique.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
internal static string? NormalizeTenantValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim().ToLowerInvariant();
}
internal static string? NormalizeProjectValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim().ToLowerInvariant();
}
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}

View File

@@ -0,0 +1,26 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateMSBuildEditorConfigFile>false</GenerateMSBuildEditorConfigFile>
</PropertyGroup>
<ItemGroup>
<EditorConfigFiles Remove="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" />
</ItemGroup>
<Target Name="EnsureGeneratedEditorConfig" BeforeTargets="ResolveEditorConfigFiles">
<WriteLinesToFile File="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" Lines="" Overwrite="false" />
</Target>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
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";
public const string Revocations = "authority_revocations";
public const string RevocationState = "authority_revocation_state";
public const string Invites = "authority_bootstrap_invites";
}
}

View File

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

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents a bootstrap invitation token for provisioning users or clients.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityBootstrapInviteDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("token")]
public string Token { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("type")]
public string Type { get; set; } = "user";
[BsonElement("provider")]
[BsonIgnoreIfNull]
public string? Provider { get; set; }
[BsonElement("target")]
[BsonIgnoreIfNull]
public string? Target { get; set; }
[BsonElement("issuedAt")]
public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("issuedBy")]
[BsonIgnoreIfNull]
public string? IssuedBy { get; set; }
[BsonElement("expiresAt")]
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddDays(2);
[BsonElement("status")]
public string Status { get; set; } = AuthorityBootstrapInviteStatuses.Pending;
[BsonElement("reservedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ReservedAt { get; set; }
[BsonElement("reservedBy")]
[BsonIgnoreIfNull]
public string? ReservedBy { get; set; }
[BsonElement("consumedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ConsumedAt { get; set; }
[BsonElement("consumedBy")]
[BsonIgnoreIfNull]
public string? ConsumedBy { get; set; }
[BsonElement("metadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? Metadata { get; set; }
}
public static class AuthorityBootstrapInviteStatuses
{
public const string Pending = "pending";
public const string Reserved = "reserved";
public const string Consumed = "consumed";
public const string Expired = "expired";
}

View File

@@ -0,0 +1,45 @@
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Captures certificate metadata associated with an mTLS-bound client.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityClientCertificateBinding
{
[BsonElement("thumbprint")]
public string Thumbprint { get; set; } = string.Empty;
[BsonElement("serialNumber")]
[BsonIgnoreIfNull]
public string? SerialNumber { get; set; }
[BsonElement("subject")]
[BsonIgnoreIfNull]
public string? Subject { get; set; }
[BsonElement("issuer")]
[BsonIgnoreIfNull]
public string? Issuer { get; set; }
[BsonElement("notBefore")]
public DateTimeOffset? NotBefore { get; set; }
[BsonElement("notAfter")]
public DateTimeOffset? NotAfter { get; set; }
[BsonElement("subjectAlternativeNames")]
public List<string> SubjectAlternativeNames { get; set; } = new();
[BsonElement("label")]
[BsonIgnoreIfNull]
public string? Label { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,69 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
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("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("certificateBindings")]
public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("disabled")]
public bool Disabled { get; set; }
}

View File

@@ -0,0 +1,82 @@
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("eventType")]
public string EventType { get; set; } = "authority.unknown";
[BsonElement("outcome")]
public string Outcome { get; set; } = "unknown";
[BsonElement("correlationId")]
[BsonIgnoreIfNull]
public string? CorrelationId { get; set; }
[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("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("plugin")]
[BsonIgnoreIfNull]
public string? Plugin { get; set; }
[BsonElement("successful")]
public bool Successful { get; set; }
[BsonElement("scopes")]
public List<string> Scopes { get; set; } = new();
[BsonElement("reason")]
[BsonIgnoreIfNull]
public string? Reason { get; set; }
[BsonElement("remoteAddress")]
[BsonIgnoreIfNull]
public string? RemoteAddress { get; set; }
[BsonElement("properties")]
public List<AuthorityLoginAttemptPropertyDocument> Properties { get; set; } = new();
[BsonElement("occurredAt")]
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Represents an additional classified property captured for an authority login attempt.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityLoginAttemptPropertyDocument
{
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("value")]
[BsonIgnoreIfNull]
public string? Value { get; set; }
[BsonElement("classification")]
public string Classification { get; set; } = "none";
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Represents a revocation entry emitted by Authority (subject/client/token/key).
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityRevocationDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
[BsonElement("category")]
public string Category { get; set; } = string.Empty;
[BsonElement("revocationId")]
public string RevocationId { get; set; } = string.Empty;
[BsonElement("tokenType")]
[BsonIgnoreIfNull]
public string? TokenType { get; set; }
[BsonElement("subjectId")]
[BsonIgnoreIfNull]
public string? SubjectId { get; set; }
[BsonElement("clientId")]
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("reason")]
[BsonIgnoreIfNull]
public string? Reason { get; set; }
[BsonElement("reasonDescription")]
[BsonIgnoreIfNull]
public string? ReasonDescription { get; set; }
[BsonElement("revokedAt")]
public DateTimeOffset RevokedAt { get; set; }
[BsonElement("effectiveAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? EffectiveAt { get; set; }
[BsonElement("expiresAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? ExpiresAt { get; set; }
[BsonElement("scopes")]
[BsonIgnoreIfNull]
public List<string>? Scopes { get; set; }
[BsonElement("fingerprint")]
[BsonIgnoreIfNull]
public string? Fingerprint { get; set; }
[BsonElement("metadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? Metadata { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,23 @@
using System;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Storage.Mongo.Documents;
[BsonIgnoreExtraElements]
public sealed class AuthorityRevocationExportStateDocument
{
[BsonId]
public string Id { get; set; } = "state";
[BsonElement("sequence")]
public long Sequence { get; set; }
[BsonElement("lastBundleId")]
[BsonIgnoreIfNull]
public string? LastBundleId { get; set; }
[BsonElement("lastIssuedAt")]
[BsonIgnoreIfNull]
public DateTimeOffset? LastIssuedAt { get; set; }
}

View File

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

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
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; }
[BsonElement("revokedReason")]
[BsonIgnoreIfNull]
public string? RevokedReason { get; set; }
[BsonElement("revokedReasonDescription")]
[BsonIgnoreIfNull]
public string? RevokedReasonDescription { get; set; }
[BsonElement("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("senderKeyThumbprint")]
[BsonIgnoreIfNull]
public string? SenderKeyThumbprint { get; set; }
[BsonElement("senderNonce")]
[BsonIgnoreIfNull]
public string? SenderNonce { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("project")]
[BsonIgnoreIfNull]
public string? Project { get; set; }
[BsonElement("devices")]
[BsonIgnoreIfNull]
public List<BsonDocument>? Devices { get; set; }
[BsonElement("revokedMetadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? RevokedMetadata { get; set; }
}

View File

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

View File

@@ -0,0 +1,129 @@
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;
using StellaOps.Authority.Storage.Mongo.Sessions;
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.AddScoped<IAuthorityMongoSessionAccessor, AuthorityMongoSessionAccessor>();
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.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
});
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
});
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
return services;
}
}

View File

@@ -0,0 +1,25 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Initialization;
internal sealed class AuthorityBootstrapInviteCollectionInitializer : IAuthorityCollectionInitializer
{
private static readonly CreateIndexModel<AuthorityBootstrapInviteDocument>[] Indexes =
{
new CreateIndexModel<AuthorityBootstrapInviteDocument>(
Builders<AuthorityBootstrapInviteDocument>.IndexKeys.Ascending(i => i.Token),
new CreateIndexOptions { Unique = true, Name = "idx_invite_token" }),
new CreateIndexModel<AuthorityBootstrapInviteDocument>(
Builders<AuthorityBootstrapInviteDocument>.IndexKeys.Ascending(i => i.Status).Ascending(i => i.ExpiresAt),
new CreateIndexOptions { Name = "idx_invite_status_expires" })
};
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
var collection = database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
await collection.Indexes.CreateManyAsync(Indexes, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,30 @@
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" }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.SenderConstraint),
new CreateIndexOptions { Name = "client_sender_constraint" }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending("certificateBindings.thumbprint"),
new CreateIndexOptions { Name = "client_cert_thumbprints" })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,35 @@
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" }),
new CreateIndexModel<AuthorityLoginAttemptDocument>(
Builders<AuthorityLoginAttemptDocument>.IndexKeys
.Ascending(a => a.CorrelationId),
new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true }),
new CreateIndexModel<AuthorityLoginAttemptDocument>(
Builders<AuthorityLoginAttemptDocument>.IndexKeys
.Ascending(a => a.Tenant)
.Descending(a => a.OccurredAt),
new CreateIndexOptions { Name = "login_attempt_tenant_time", Sparse = true })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
}
}

View File

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

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