Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).

This commit is contained in:
dohertj2
2026-06-01 03:59:23 -04:00
commit 37e23cf9f2
73 changed files with 6836 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
+12
View File
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- LDAP -->
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<!-- Data -->
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<!-- Extensions -->
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<!-- ASP.NET Core Authentication / Authorization -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
<!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>
+82
View File
@@ -0,0 +1,82 @@
# ZB.MOM.WW.Auth
Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central authentication process or network hop; auth logic runs in-process alongside the application.
---
## Packages
| Package | Description | Key Dependencies |
|---|---|---|
| `ZB.MOM.WW.Auth.Abstractions` | Auth contracts, canonical role constants, and shared types (`LdapOptions`, `LdapAuthResult`, `ILdapAuthService`, `IApiKeyStore`). No runtime dependencies beyond the BCL. | — |
| `ZB.MOM.WW.Auth.Ldap` | LDAP authentication service: bind-then-search-then-bind against GLAuth or Active Directory; RFC 4514-aware group extraction; fail-closed. | `Abstractions`, `Novell.Directory.Ldap.NETStandard` |
| `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with pepper-based PBKDF2 hashing, rotation, and audit log. Includes a `MigrationHostedService` that runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` |
| `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core DI helpers (`AddZbAuth`), cookie defaults, claim-type constants, and `LdapOptionsValidator` registration. Wires together Ldap + ApiKeys + cookie middleware. | `Abstractions`, `Ldap`, `ApiKeys`, `Microsoft.AspNetCore.App` |
---
## Consumer Matrix
| Consumer | Abstractions | Ldap | ApiKeys | AspNetCore |
|---|:---:|:---:|:---:|:---:|
| **OtOpcUa** | yes | yes | — | yes |
| **MxAccessGateway** | yes | yes | yes | yes |
| **ScadaBridge** | yes | yes | yes | yes |
`ApiKeys` is NOT used by OtOpcUa (that app authenticates human operators via LDAP + cookies only; machine-to-machine access is out of scope).
---
## Versioning
All four packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps all four packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
---
## Running the opt-in LDAP integration test
The GLAuth integration test (`GLAuthIntegrationTests`) is **skipped by default** and does not affect the normal test run. To exercise it against a live GLAuth instance:
1. Start the GLAuth Docker stack from the sibling repo:
```
cd ~/Desktop/ScadaBridge/infra/glauth
docker compose up -d
```
2. Set the required environment variables and run the test:
```bash
export ZB_LDAP_IT=1
export ZB_LDAP_SVC_DN="cn=svc,dc=lmxopcua,dc=local"
export ZB_LDAP_SVC_PW="svcpass"
export ZB_LDAP_USER="alice"
export ZB_LDAP_PW="alicepass"
dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
--filter "FullyQualifiedName~GLAuthIntegrationTests"
```
All other variables (`ZB_LDAP_SERVER`, `ZB_LDAP_PORT`, `ZB_LDAP_BASE`, `ZB_LDAP_USERATTR`) default to sensible GLAuth values and are optional. The test also probes TCP reachability before attempting auth and skips if the server is not contactable.
---
## Publishing packages
Use `build/push.sh` to pack and push to the Gitea NuGet feed:
```bash
export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
export GITEA_NUGET_KEY="your-gitea-token"
./build/push.sh
```
The script runs `dotnet pack -c Release` then `dotnet nuget push --skip-duplicate`.
---
## Design documentation
Full design docs live in the `components/auth` folder of the SCADA project notes:
- `~/Desktop/scadaproj/components/auth/spec/SPEC.md` — overall auth specification
- `~/Desktop/scadaproj/components/auth/spec/CANONICAL-ROLES.md` — role taxonomy
- `~/Desktop/scadaproj/components/auth/shared-contract/` — shared contract types
+13
View File
@@ -0,0 +1,13 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj" />
<Project Path="src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj" />
<Project Path="src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj" />
<Project Path="src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ZB.MOM.WW.Auth.ApiKeys.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZB.MOM.WW.Auth.AspNetCore.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.Auth.Ldap.Tests/ZB.MOM.WW.Auth.Ldap.Tests.csproj" />
</Folder>
</Solution>
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# pack.sh — produce the ZB.MOM.WW.Auth NuGet packages into ./artifacts.
#
# Usage:
# ./build/pack.sh
set -euo pipefail
dotnet pack -c Release -o ./artifacts
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# push.sh — pack and push all ZB.MOM.WW.Auth NuGet packages to the Gitea feed.
#
# Required environment variables:
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
# e.g. https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json
# GITEA_NUGET_KEY — Gitea access token with package:write permission
#
# Usage:
# export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
# export GITEA_NUGET_KEY="your-gitea-token"
# ./build/push.sh
set -euo pipefail
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
dotnet pack -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" \
--source "$GITEA_NUGET_SOURCE" \
--api-key "$GITEA_NUGET_KEY" \
--skip-duplicate
@@ -0,0 +1,69 @@
namespace ZB.MOM.WW.Auth.Abstractions.ApiKeys;
public sealed record ApiKeyOptions
{
public string TokenPrefix { get; init; } = "mxgw";
public string PepperSecretName { get; init; } = "";
public string SqlitePath { get; init; } = "";
public bool RunMigrationsOnStartup { get; init; } = true;
}
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
public sealed record ApiKeyVerification(bool Succeeded, ApiKeyIdentity? Identity, ApiKeyFailure? Failure);
public interface IApiKeyVerifier
{
Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct);
}
/// <remarks>
/// As a positional record, <see cref="SecretHash"/> (<c>byte[]</c>) participates in equality
/// BY REFERENCE. Two records whose <c>SecretHash</c> arrays contain identical bytes are NOT
/// considered equal by <see cref="object.Equals(object?)"/>. Callers must not rely on value
/// equality for <see cref="SecretHash"/>; use <see cref="System.MemoryExtensions.SequenceEqual{T}"/>
/// or similar for content comparison.
/// </remarks>
public sealed record ApiKeyRecord(
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
IReadOnlySet<string> Scopes, string? ConstraintsJson,
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
public interface IApiKeyStore
{
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct);
Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct);
Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct);
}
public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? RemoteAddress, DateTimeOffset CreatedUtc, string? Details);
/// <summary>
/// Hash-free projection of an API-key record, safe to enumerate and surface to admins.
/// Deliberately omits <c>SecretHash</c> so that listing keys can never leak secret material.
/// </summary>
public sealed record ApiKeyListItem(
string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet<string> Scopes,
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
public interface IApiKeyAdminStore
{
Task CreateAsync(ApiKeyRecord record, CancellationToken ct);
Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct);
Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct);
Task<bool> DeleteAsync(string keyId, CancellationToken ct);
/// <summary>
/// Enumerates all API keys as hash-free <see cref="ApiKeyListItem"/> projections, newest first.
/// The secret hash is never selected, so callers cannot use this to recover secret material.
/// </summary>
Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct);
}
public interface IApiKeyAuditStore
{
Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct);
Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct);
}
@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
public enum LdapTransport { Ldaps, StartTls, None }
public sealed record LdapOptions
{
public bool Enabled { get; init; } = true;
public string Server { get; init; } = "localhost";
public int Port { get; init; } = 3893;
public LdapTransport Transport { get; init; } = LdapTransport.Ldaps;
public bool AllowInsecure { get; init; }
public string SearchBase { get; init; } = "";
public string ServiceAccountDn { get; init; } = "";
public string ServiceAccountPassword { get; init; } = "";
public string UserNameAttribute { get; init; } = "cn";
public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000;
}
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
public sealed record LdapAuthResult(bool Succeeded, string Username, string DisplayName, IReadOnlyList<string> Groups, LdapAuthFailure? Failure)
{
public static LdapAuthResult Success(string username, string displayName, IReadOnlyList<string> groups) => new(true, username, displayName, groups, null);
public static LdapAuthResult Fail(LdapAuthFailure failure) => new(false, "", "", Array.Empty<string>(), failure);
}
public interface ILdapAuthService
{
/// <summary>
/// Authenticates <paramref name="username"/> against the directory by bind-then-search and
/// returns the outcome, including the resolved display name and group memberships on success.
/// </summary>
/// <param name="username">The login name to authenticate.</param>
/// <param name="password">The credential to bind with.</param>
/// <param name="ct">A token to request cancellation of the operation.</param>
/// <returns>The authentication result.</returns>
/// <remarks>
/// The cancellation token is observed at entry only. Implementations backed by synchronous
/// LDAP clients cannot abort an in-flight bind or search once it has been dispatched, so full
/// cooperative cancellation is not guaranteed mid-call: a request that has already reached the
/// directory will run to completion (subject to the configured connection timeout) even if the
/// token is cancelled.
/// </remarks>
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct);
}
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.Auth.Abstractions.Roles;
public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator }
public sealed record GroupRoleMapping<TRole>(IReadOnlyList<TRole> Roles, object? Scope);
/// <summary>
/// Maps a user's directory group memberships to a set of roles (typically
/// <see cref="CanonicalRole"/>) plus an opaque scope payload.
/// </summary>
/// <typeparam name="TRole">The role vocabulary, e.g. <see cref="CanonicalRole"/>.</typeparam>
/// <remarks>
/// This library ships only the contract. Concrete canonical→native mappers are provided
/// per-consumer (config-backed for OtOpcUa/mxaccessgw, DB/delegate-backed for ScadaBridge),
/// because the backing store and the canonical→native role/permission expansion stay per-project
/// (see <c>scadaproj/components/auth/GAPS.md</c>, gaps C1/C2). No default implementation is shipped here.
/// </remarks>
public interface IGroupRoleMapper<TRole>
{
/// <summary>
/// Maps the supplied <paramref name="groups"/> to the roles and scope they grant.
/// </summary>
/// <param name="groups">The user's directory group memberships.</param>
/// <param name="ct">A token to request cancellation of the operation.</param>
/// <returns>The roles granted and an opaque scope payload.</returns>
Task<GroupRoleMapping<TRole>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct);
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Auth.Abstractions</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Auth contracts and canonical roles for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
</PropertyGroup>
</Project>
@@ -0,0 +1,206 @@
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Admin;
/// <summary>
/// Result of a verb that yields a freshly assembled token (create-key / rotate-key).
/// The <see cref="Token"/> is the ONLY moment the secret is ever available; it is never
/// retrievable afterwards. A <c>null</c> <see cref="Token"/> indicates the verb failed
/// (for example, rotating a key that does not exist).
/// </summary>
public sealed record CreateKeyResult(string KeyId, string? Token);
/// <summary>Result of a mutating verb that succeeds or fails without yielding a token.</summary>
public sealed record KeyActionResult(bool Succeeded, string? Message);
/// <summary>
/// Reusable, front-end-agnostic API-key administration command set. Each verb returns a
/// structured result and performs no console I/O, so consumers can wire their own CLI or HTTP
/// front-end on top. Audit is wired here (the command layer): every mutating verb appends an
/// <see cref="ApiKeyAuditEntry"/> via <see cref="IApiKeyAuditStore"/>.
/// </summary>
/// <remarks>
/// <para>
/// <c>create-key</c> and <c>rotate-key</c> return the assembled token EXACTLY ONCE — the only
/// time the secret is ever available. No other result carries the secret or its hash;
/// <see cref="ApiKeyListItem"/> is a hash-free projection by construction.
/// </para>
/// </remarks>
public sealed class ApiKeyAdminCommands
{
private readonly ApiKeyOptions _options;
private readonly IApiKeyAdminStore _adminStore;
private readonly IApiKeyAuditStore _auditStore;
private readonly IApiKeyPepperProvider _pepperProvider;
private readonly SqliteAuthStoreMigrator _migrator;
private readonly TimeProvider _clock;
/// <summary>Creates the command set over the supplied stores and options.</summary>
/// <param name="options">API-key options (token prefix, store path, ...).</param>
/// <param name="adminStore">Mutating store (create / revoke / rotate / delete / list).</param>
/// <param name="auditStore">Append-only audit store wired into every mutating verb.</param>
/// <param name="pepperProvider">Resolves the pepper used to hash secrets.</param>
/// <param name="migrator">Schema migrator used by <see cref="InitDbAsync"/>.</param>
/// <param name="clock">Optional clock; defaults to <see cref="TimeProvider.System"/>.</param>
public ApiKeyAdminCommands(
ApiKeyOptions options,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
IApiKeyPepperProvider pepperProvider,
SqliteAuthStoreMigrator migrator,
TimeProvider? clock = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(adminStore);
ArgumentNullException.ThrowIfNull(auditStore);
ArgumentNullException.ThrowIfNull(pepperProvider);
ArgumentNullException.ThrowIfNull(migrator);
_options = options;
_adminStore = adminStore;
_auditStore = auditStore;
_pepperProvider = pepperProvider;
_migrator = migrator;
_clock = clock ?? TimeProvider.System;
}
/// <summary>
/// init-db: applies the schema migration, then appends an <c>init-db</c> audit entry.
/// </summary>
public async Task InitDbAsync(string? remoteAddress, CancellationToken ct)
{
await _migrator.MigrateAsync(ct).ConfigureAwait(false);
await AppendAuditAsync(keyId: null, "init-db", remoteAddress, details: null, ct).ConfigureAwait(false);
}
/// <summary>
/// create-key: generates a secret, persists its hash, appends a <c>create-key</c> audit entry,
/// and returns the assembled token <c>&lt;prefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</c> EXACTLY ONCE.
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> CreateKeyAsync(
string keyId,
string displayName,
IReadOnlySet<string> scopes,
string? constraintsJson,
string? remoteAddress,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (keyId.Contains('_'))
throw new ArgumentException("keyId must not contain '_'.", nameof(keyId));
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentNullException.ThrowIfNull(scopes);
string pepper = RequirePepper();
string secret = ApiKeySecretGenerator.NewSecret();
byte[] secretHash = ApiKeySecretHasher.Hash(secret, pepper);
DateTimeOffset now = _clock.GetUtcNow();
var record = new ApiKeyRecord(
KeyId: keyId,
KeyPrefix: $"{_options.TokenPrefix}_{keyId}",
SecretHash: secretHash,
DisplayName: displayName,
Scopes: scopes,
ConstraintsJson: constraintsJson,
CreatedUtc: now,
LastUsedUtc: null,
RevokedUtc: null);
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
return new CreateKeyResult(keyId, AssembleToken(keyId, secret));
}
/// <summary>
/// list-keys: returns the hash-free <see cref="ApiKeyListItem"/> projection of every key,
/// newest first. This is a read, so it appends no audit entry and never carries secret material.
/// </summary>
public Task<IReadOnlyList<ApiKeyListItem>> ListKeysAsync(CancellationToken ct) =>
_adminStore.ListAsync(ct);
/// <summary>
/// revoke-key: marks the key revoked and appends a <c>revoke-key</c> audit entry.
/// All attempts are audited, including failures (key not found or already revoked) — this is
/// intentional to maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> RevokeKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
DateTimeOffset now = _clock.GetUtcNow();
bool revoked = await _adminStore.RevokeAsync(keyId, now, ct).ConfigureAwait(false);
string status = revoked ? "revoked" : "not-found-or-already-revoked";
await AppendAuditAsync(keyId, "revoke-key", remoteAddress, status, ct).ConfigureAwait(false);
return new KeyActionResult(revoked, status);
}
/// <summary>
/// rotate-key: replaces the stored secret with a freshly generated one and appends a
/// <c>rotate-key</c> audit entry. Returns a <see cref="CreateKeyResult"/> whose token is the new
/// secret (shown once); a <c>null</c> token indicates the key did not exist.
/// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail.
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
string pepper = RequirePepper();
string secret = ApiKeySecretGenerator.NewSecret();
byte[] newHash = ApiKeySecretHasher.Hash(secret, pepper);
bool rotated = await _adminStore.RotateAsync(keyId, newHash, ct).ConfigureAwait(false);
string status = rotated ? "rotated" : "not-found";
await AppendAuditAsync(keyId, "rotate-key", remoteAddress, status, ct).ConfigureAwait(false);
return new CreateKeyResult(keyId, rotated ? AssembleToken(keyId, secret) : null);
}
/// <summary>
/// delete-key: removes the key (only succeeds once it has been revoked) and appends a
/// <c>delete-key</c> audit entry.
/// All attempts are audited, including failures (key not found or not yet revoked) — this is
/// intentional to maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> DeleteKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
bool deleted = await _adminStore.DeleteAsync(keyId, ct).ConfigureAwait(false);
string status = deleted ? "deleted" : "not-found-or-not-revoked";
await AppendAuditAsync(keyId, "delete-key", remoteAddress, status, ct).ConfigureAwait(false);
return new KeyActionResult(deleted, status);
}
private string RequirePepper()
{
string? pepper = _pepperProvider.GetPepper();
if (string.IsNullOrWhiteSpace(pepper))
{
throw new InvalidOperationException("pepper unavailable");
}
return pepper;
}
private string AssembleToken(string keyId, string secret) =>
$"{_options.TokenPrefix}_{keyId}_{secret}";
private Task AppendAuditAsync(
string? keyId, string eventType, string? remoteAddress, string? details, CancellationToken ct) =>
_auditStore.AppendAsync(
new ApiKeyAuditEntry(keyId, eventType, remoteAddress, _clock.GetUtcNow(), details),
ct);
}
@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.Auth.ApiKeys;
internal sealed record ParsedApiKey(string KeyId, string Secret);
internal static class ApiKeyParser
{
private const string BearerPrefix = "Bearer ";
/// <summary>
/// Attempts to parse an Authorization header value or a raw token into a <see cref="ParsedApiKey"/>.
/// Accepts an optional case-insensitive "Bearer " scheme prefix before the token.
/// Token format: <c>&lt;tokenPrefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</c>.
/// The secret may itself contain underscores; only the first underscore after the key-id is used as
/// the key-id/secret separator.
/// </summary>
/// <param name="authorizationHeaderOrToken">Authorization header value or raw token string.</param>
/// <param name="tokenPrefix">Expected token prefix (e.g. "mxgw"), without trailing underscore.</param>
/// <returns>A <see cref="ParsedApiKey"/> on success, or <c>null</c> if the input is malformed.</returns>
public static ParsedApiKey? TryParse(string? authorizationHeaderOrToken, string tokenPrefix)
{
if (string.IsNullOrWhiteSpace(authorizationHeaderOrToken))
return null;
string token = authorizationHeaderOrToken;
// Strip optional "Bearer " prefix (case-insensitive).
if (token.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
token = token[BearerPrefix.Length..].Trim();
// Token must start with "<prefix>_"
string requiredPrefix = tokenPrefix + "_";
if (!token.StartsWith(requiredPrefix, StringComparison.OrdinalIgnoreCase))
return null;
// Everything after "<prefix>_" is "<keyId>_<secret>"
string keyPayload = token[requiredPrefix.Length..];
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
// separatorIndex <= 0 means no underscore or empty keyId
// separatorIndex == keyPayload.Length - 1 means empty secret
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
return null;
string keyId = keyPayload[..separatorIndex];
string secret = keyPayload[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
return null;
return new ParsedApiKey(keyId, secret);
}
}
@@ -0,0 +1,21 @@
using System.Security.Cryptography;
namespace ZB.MOM.WW.Auth.ApiKeys;
internal static class ApiKeySecretGenerator
{
/// <summary>
/// Generates a new cryptographically secure API key secret.
/// Returns 32 random bytes encoded as URL-safe base64 (no padding, no '+', no '/').
/// </summary>
public static string NewSecret()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
@@ -0,0 +1,30 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.Auth.ApiKeys;
internal static class ApiKeySecretHasher
{
/// <summary>
/// Computes HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)).
/// </summary>
public static byte[] Hash(string secret, string pepper)
{
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
using HMACSHA256 hmac = new(pepperBytes);
return hmac.ComputeHash(secretBytes);
}
/// <summary>
/// Returns true iff HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)) equals
/// <paramref name="expectedHash"/>, using a constant-time comparison.
/// Returns false (without throwing) if the lengths differ.
/// </summary>
public static bool Verify(string secret, string pepper, byte[] expectedHash)
{
byte[] actualHash = Hash(secret, pepper);
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
}
}
@@ -0,0 +1,75 @@
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys;
/// <summary>
/// Verifies presented API-key credentials against the key store, returning a structured,
/// discriminated result. The pipeline is fail-closed: any inability to positively verify a
/// credential yields a failure rather than a success.
/// </summary>
/// <remarks>
/// The failure reason is discriminated for the caller/audit pipeline, but the verifier returns a
/// structured result rather than throwing (the caller decides the opaque client-facing message).
/// The only exception path is cancellation. A successful identity carries the key's scopes and the
/// opaque <c>ConstraintsJson</c> blob (which the verifier does not interpret); it never carries the
/// presented secret, the pepper, or the stored secret hash.
/// </remarks>
public sealed class ApiKeyVerifier(
ApiKeyOptions options,
IApiKeyStore store,
IApiKeyPepperProvider pepperProvider,
TimeProvider? timeProvider = null) : IApiKeyVerifier
{
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// 1. Parse the header/token. Malformed or wrong-prefix credentials are indistinguishable
// from a missing credential and are reported uniformly.
ParsedApiKey? parsed = ApiKeyParser.TryParse(authorizationHeader, options.TokenPrefix);
if (parsed is null)
{
return Fail(ApiKeyFailure.MissingOrMalformed);
}
// 2. Resolve the pepper before touching the store. Without it, no verification is possible,
// so we fail closed (and avoid an unnecessary store lookup).
string? pepper = pepperProvider.GetPepper();
if (string.IsNullOrWhiteSpace(pepper))
{
return Fail(ApiKeyFailure.PepperUnavailable);
}
// 3. Look up the record (including revoked ones) so we can discriminate not-found vs revoked.
ApiKeyRecord? record = await store.FindByKeyIdAsync(parsed.KeyId, ct).ConfigureAwait(false);
if (record is null)
{
return Fail(ApiKeyFailure.KeyNotFound);
}
// 4. Reject revoked keys.
if (record.RevokedUtc is not null)
{
return Fail(ApiKeyFailure.KeyRevoked);
}
// 5. Constant-time secret comparison.
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
{
return Fail(ApiKeyFailure.SecretMismatch);
}
// 6. Record successful use, then return the identity (no secret/hash/pepper included).
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
return new ApiKeyVerification(
Succeeded: true,
Identity: new ApiKeyIdentity(record.KeyId, record.DisplayName, record.Scopes, record.ConstraintsJson),
Failure: null);
}
private static ApiKeyVerification Fail(ApiKeyFailure failure) => new(false, null, failure);
}
@@ -0,0 +1,28 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
/// <summary>
/// Runs the API-key SQLite schema migration at application startup when
/// <see cref="ApiKeyOptions.RunMigrationsOnStartup"/> is <see langword="true"/>.
/// The migration is idempotent, so repeated restarts are safe.
/// </summary>
internal sealed class ApiKeyMigrationHostedService(
SqliteAuthStoreMigrator migrator,
IOptions<ApiKeyOptions> options) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (options.Value.RunMigrationsOnStartup)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,74 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
/// <summary>
/// Dependency-injection helpers that wire up the ZB.MOM.WW API-key authentication provider
/// from configuration. These compose the SQLite-backed stores and the configuration-backed
/// pepper provider so a consuming app registers the verifier with a single call.
/// </summary>
public static class ApiKeyServiceCollectionExtensions
{
/// <summary>
/// Registers API-key authentication: binds <see cref="ApiKeyOptions"/> from the
/// configuration section at <paramref name="sectionPath"/>, wires up the SQLite-backed
/// stores and the configuration-backed pepper provider, and registers
/// <see cref="IApiKeyVerifier"/>.
/// </summary>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="sectionPath">Path of the configuration section holding the API-key options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbApiKeyAuth(
this IServiceCollection services,
IConfiguration config,
string sectionPath)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
services.Configure<ApiKeyOptions>(config.GetSection(sectionPath));
// The pepper provider reads the live IConfiguration on each call. In an ASP.NET Core
// host IConfiguration is already registered; register the caller-supplied instance here
// (TryAdd, so the host's own registration wins when present) so the provider resolves
// even in a bare ServiceCollection.
services.TryAddSingleton(config);
services.TryAddSingleton<IApiKeyPepperProvider, ConfigurationApiKeyPepperProvider>();
// One connection factory targets the configured SQLite path. Singleton: it is
// stateless aside from the path and opens a fresh connection per operation.
services.TryAddSingleton(sp =>
new AuthSqliteConnectionFactory(
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.SqlitePath));
services.TryAddSingleton<IApiKeyStore>(sp =>
new SqliteApiKeyStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
services.TryAddSingleton<IApiKeyAdminStore>(sp =>
new SqliteApiKeyAdminStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
services.TryAddSingleton<IApiKeyAuditStore>(sp =>
new SqliteApiKeyAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
services.TryAddSingleton<IApiKeyVerifier>(sp =>
new ApiKeyVerifier(
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
sp.GetRequiredService<IApiKeyStore>(),
sp.GetRequiredService<IApiKeyPepperProvider>()));
// Migrator: singleton, constructed from the already-registered connection factory.
// Needed before any store operations so the schema exists.
services.TryAddSingleton(sp =>
new SqliteAuthStoreMigrator(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
// Hosted service that runs migrations on startup when ApiKeyOptions.RunMigrationsOnStartup.
services.AddHostedService<ApiKeyMigrationHostedService>();
return services;
}
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
/// <summary>
/// Configuration-backed <see cref="IApiKeyPepperProvider"/> that resolves the API-key pepper
/// from <see cref="IConfiguration"/> using the key name in
/// <see cref="ApiKeyOptions.PepperSecretName"/>.
/// </summary>
/// <remarks>
/// The pepper is read live from configuration on each call so that a secret rotated in the
/// underlying provider (e.g. an environment variable or a refreshed secret store) takes effect
/// without restarting the process. When the secret name is unconfigured or the value is absent,
/// <see cref="GetPepper"/> returns <see langword="null"/>/empty, which the verifier treats as a
/// fail-closed "pepper unavailable" condition.
/// </remarks>
public sealed class ConfigurationApiKeyPepperProvider(
IConfiguration config,
IOptions<ApiKeyOptions> options) : IApiKeyPepperProvider
{
/// <inheritdoc />
public string? GetPepper()
{
string secretName = options.Value.PepperSecretName;
return string.IsNullOrWhiteSpace(secretName)
? null
: config[secretName];
}
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.Auth.ApiKeys;
/// <summary>
/// Resolves the secret pepper used to verify API-key secret hashes.
/// </summary>
/// <remarks>
/// Implementations resolve the pepper from a configured secret source. A concrete,
/// configuration-backed provider is wired up separately; this abstraction lets the
/// verifier fail closed when the pepper cannot be resolved.
/// </remarks>
public interface IApiKeyPepperProvider
{
/// <summary>
/// Returns the resolved pepper, or <c>null</c>/empty if it is currently unavailable.
/// </summary>
/// <returns>The pepper value, or <c>null</c>/whitespace when unavailable.</returns>
string? GetPepper();
}
@@ -0,0 +1,87 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>
/// Factory for creating and opening SQLite connections to the API-key store.
/// </summary>
public sealed class AuthSqliteConnectionFactory
{
/// <summary>
/// Busy timeout applied to every connection. SQLite retries a busy database for
/// this long before surfacing <c>SQLITE_BUSY</c>, so the concurrent
/// mark-used / audit-append writers degrade gracefully under load instead of
/// failing the request path.
/// </summary>
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
private readonly string _sqlitePath;
/// <summary>Creates a factory targeting the database at <paramref name="sqlitePath"/>.</summary>
/// <param name="sqlitePath">Filesystem path of the SQLite database file.</param>
public AuthSqliteConnectionFactory(string sqlitePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
_sqlitePath = sqlitePath;
}
/// <summary>
/// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
/// busy timeout.
/// </summary>
public SqliteConnection CreateConnection()
{
string? directory = Path.GetDirectoryName(_sqlitePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
SqliteConnectionStringBuilder builder = new()
{
DataSource = _sqlitePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = true,
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
};
return new SqliteConnection(builder.ToString());
}
/// <summary>
/// Creates a SQLite connection, opens it, and configures WAL journaling and a
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An opened and configured SQLite connection.</returns>
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
SqliteConnection connection = CreateConnection();
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
private static async Task ConfigureConnectionAsync(
SqliteConnection connection,
CancellationToken cancellationToken)
{
// WAL is a persistent, database-level setting; re-applying it per connection
// is cheap and a no-op once set. busy_timeout is per-connection state.
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
$"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,35 @@
using System.Text.Json;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>
/// Serializes API-key scope sets to a canonical JSON array. Scopes are sorted with
/// <see cref="StringComparer.Ordinal"/> so that equal sets always produce identical
/// column text, regardless of insertion order.
/// </summary>
public static class ScopeSerializer
{
/// <summary>Serializes scopes to an ordinal-sorted JSON array.</summary>
/// <param name="scopes">The scopes to serialize.</param>
/// <returns>A JSON array string with elements sorted ordinally.</returns>
public static string Serialize(IReadOnlySet<string> scopes)
{
ArgumentNullException.ThrowIfNull(scopes);
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
}
/// <summary>Deserializes scopes from a JSON array string.</summary>
/// <param name="value">The JSON string to deserialize; may be null or empty.</param>
/// <returns>An ordinal-compared set of scopes; empty when the input is null/blank.</returns>
public static IReadOnlySet<string> Deserialize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
}
}
@@ -0,0 +1,143 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete).
/// </summary>
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
{
/// <inheritdoc />
public async Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(record);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
INSERT INTO api_keys (
key_id, key_prefix, secret_hash, display_name, scopes,
constraints, created_utc, last_used_utc, revoked_utc)
VALUES (
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
$constraints, $created_utc, $last_used_utc, $revoked_utc);
""";
command.Parameters.AddWithValue("$key_id", record.KeyId);
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash;
command.Parameters.AddWithValue("$display_name", record.DisplayName);
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes));
command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value);
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET revoked_utc = $revoked_utc
WHERE key_id = $key_id AND revoked_utc IS NULL;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O"));
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(newSecretHash);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET secret_hash = $secret_hash,
last_used_utc = NULL,
revoked_utc = NULL
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = newSecretHash;
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
DELETE FROM api_keys
WHERE key_id = $key_id AND revoked_utc IS NOT NULL;
""";
command.Parameters.AddWithValue("$key_id", keyId);
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
// Deliberately omits secret_hash so listing can never leak secret material.
command.CommandText = """
SELECT key_id, key_prefix, display_name, scopes, constraints,
created_utc, last_used_utc, revoked_utc
FROM api_keys
ORDER BY created_utc DESC, key_id DESC;
""";
List<ApiKeyListItem> items = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
items.Add(new ApiKeyListItem(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
DisplayName: reader.GetString(2),
Scopes: ScopeSerializer.Deserialize(reader.GetString(3)),
ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6),
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7)));
}
return items;
}
}
@@ -0,0 +1,67 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>SQLite-backed, append-only audit store for API-key events.</summary>
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
{
/// <inheritdoc />
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(entry);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
INSERT INTO api_key_audit (key_id, event_type, remote_address, created_utc, details)
VALUES ($key_id, $event_type, $remote_address, $created_utc, $details);
""";
command.Parameters.AddWithValue("$key_id", (object?)entry.KeyId ?? DBNull.Value);
command.Parameters.AddWithValue("$event_type", entry.EventType);
command.Parameters.AddWithValue("$remote_address", (object?)entry.RemoteAddress ?? DBNull.Value);
command.Parameters.AddWithValue("$created_utc", entry.CreatedUtc.ToString("O"));
command.Parameters.AddWithValue("$details", (object?)entry.Details ?? DBNull.Value);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
if (limit <= 0)
{
return [];
}
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT key_id, event_type, remote_address, created_utc, details
FROM api_key_audit
ORDER BY audit_id DESC
LIMIT $limit;
""";
command.Parameters.AddWithValue("$limit", limit);
List<ApiKeyAuditEntry> entries = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
entries.Add(new ApiKeyAuditEntry(
KeyId: reader.IsDBNull(0) ? null : reader.GetString(0),
EventType: reader.GetString(1),
RemoteAddress: reader.IsDBNull(2) ? null : reader.GetString(2),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(3)),
Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
}
return entries;
}
}
@@ -0,0 +1,76 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>SQLite-backed read store for API-key records.</summary>
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
{
private const string SelectColumns =
"key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc";
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
return FindAsync(keyId, requireActive: false, ct);
}
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
return FindAsync(keyId, requireActive: true, ct);
}
/// <inheritdoc />
public async Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET last_used_utc = $last_used_utc
WHERE key_id = $key_id AND revoked_utc IS NULL;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$last_used_utc", whenUtc.ToString("O"));
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private async Task<ApiKeyRecord?> FindAsync(string keyId, bool requireActive, CancellationToken ct)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = requireActive
? $"SELECT {SelectColumns} FROM api_keys WHERE key_id = $key_id AND revoked_utc IS NULL;"
: $"SELECT {SelectColumns} FROM api_keys WHERE key_id = $key_id;";
command.Parameters.AddWithValue("$key_id", keyId);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
{
return null;
}
return ReadRecord(reader);
}
internal static ApiKeyRecord ReadRecord(SqliteDataReader reader) => new(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
SecretHash: reader.GetFieldValue<byte[]>(2),
DisplayName: reader.GetString(3),
Scopes: ScopeSerializer.Deserialize(reader.GetString(4)),
ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8));
}
@@ -0,0 +1,64 @@
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>
/// Schema constants and table DDL for the API-key SQLite store.
/// </summary>
public static class SqliteAuthSchema
{
/// <summary>The schema version this build creates and supports.</summary>
public const int CurrentVersion = 1;
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
/// <summary>Name of the table storing API-key records.</summary>
public const string ApiKeysTable = "api_keys";
/// <summary>Name of the append-only audit table.</summary>
public const string ApiKeyAuditTable = "api_key_audit";
/// <summary>DDL creating the single-row schema-version table.</summary>
public const string CreateSchemaVersionTable = """
CREATE TABLE IF NOT EXISTS schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL,
applied_utc TEXT NOT NULL
);
""";
/// <summary>DDL creating the API-key record table.</summary>
public const string CreateApiKeysTable = """
CREATE TABLE IF NOT EXISTS api_keys (
key_id TEXT PRIMARY KEY,
key_prefix TEXT NOT NULL,
secret_hash BLOB NOT NULL,
display_name TEXT NOT NULL,
scopes TEXT NOT NULL,
constraints TEXT NULL,
created_utc TEXT NOT NULL,
last_used_utc TEXT NULL,
revoked_utc TEXT NULL
);
""";
/// <summary>DDL creating the append-only audit table.</summary>
public const string CreateApiKeyAuditTable = """
CREATE TABLE IF NOT EXISTS api_key_audit (
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
key_id TEXT NULL,
event_type TEXT NOT NULL,
remote_address TEXT NULL,
created_utc TEXT NOT NULL,
details TEXT NULL
);
""";
/// <summary>DDL creating supporting indexes (idempotent).</summary>
public const string CreateIndexes = """
CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc
ON api_keys (revoked_utc);
CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc
ON api_key_audit (key_id, created_utc);
""";
}
@@ -0,0 +1,130 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>Thrown when the auth store cannot be migrated to the supported schema.</summary>
public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message);
/// <summary>
/// Creates the API-key store schema and records the applied version. Idempotent: it
/// is safe to run repeatedly. Refuses to run against a database whose on-disk version
/// is newer than this build supports.
/// </summary>
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory)
{
/// <summary>Applies the schema migration to the auth store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="AuthStoreMigrationException">
/// The on-disk schema version is newer than <see cref="SqliteAuthSchema.CurrentVersion"/>.
/// </exception>
public async Task MigrateAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteTransaction transaction =
(SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false);
int existingVersion =
await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
if (existingVersion > SqliteAuthSchema.CurrentVersion)
{
throw new AuthStoreMigrationException(
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
}
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<int> ReadExistingSchemaVersionAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await using SqliteCommand tableExistsCommand = connection.CreateCommand();
tableExistsCommand.Transaction = transaction;
tableExistsCommand.CommandText = """
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table' AND name = $table_name;
""";
tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable);
long tableCount =
(long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
if (tableCount == 0)
{
return 0;
}
await using SqliteCommand versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = """
SELECT version
FROM schema_version
WHERE id = 1;
""";
object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return version is null || version == DBNull.Value
? 0
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
}
private static async Task ApplyVersionOneAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(
connection,
transaction,
string.Join(
"\n",
SqliteAuthSchema.CreateSchemaVersionTable,
SqliteAuthSchema.CreateApiKeysTable,
SqliteAuthSchema.CreateApiKeyAuditTable,
SqliteAuthSchema.CreateIndexes),
cancellationToken).ConfigureAwait(false);
}
private static async Task WriteSchemaVersionAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await using SqliteCommand versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = """
INSERT INTO schema_version (id, version, applied_utc)
VALUES (1, $version, $applied_utc)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
applied_utc = excluded.applied_utc;
""";
versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion);
versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task ExecuteNonQueryAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string commandText,
CancellationToken cancellationToken)
{
await using SqliteCommand command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = commandText;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,20 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>
/// Shared helpers for reading round-trippable timestamps out of the SQLite stores.
/// All timestamps are persisted with the round-trip ("O") format, so parsing is centralized
/// here to keep the three stores DRY and consistent.
/// </summary>
internal static class SqliteValueParsing
{
/// <summary>Parses a round-trip ("O") formatted timestamp written by the stores.</summary>
internal static DateTimeOffset ParseUtc(string value) =>
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
/// <summary>Reads a nullable round-trip timestamp at <paramref name="ordinal"/>.</summary>
internal static DateTimeOffset? ReadNullableUtc(SqliteDataReader reader, int ordinal) =>
reader.IsDBNull(ordinal) ? null : ParseUtc(reader.GetString(ordinal));
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" />
<!--
Lightweight Microsoft.Extensions.* abstractions back the DI helpers (AddZbApiKeyAuth):
IServiceCollection / TryAdd* / Configure (DependencyInjection.Abstractions), IOptions
(Options), IConfiguration (Configuration.Abstractions), and IHostedService / AddHostedService
(Hosting.Abstractions). These are plain libraries — no FrameworkReference — so an LDAP-only
consumer still pays nothing for ApiKeys, while an API-key consumer wires up with one call.
-->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<!-- Supplies the Configure<TOptions>(IConfiguration) binding overload used by AddZbApiKeyAuth. -->
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Auth.ApiKeys</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>SQLite-backed API-key store with pepper-based hashing for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.AspNetCore;
/// <summary>
/// Dependency-injection helpers that wire up the ZB.MOM.WW LDAP authentication provider
/// from configuration. Composes the concrete implementation living in the
/// <c>ZB.MOM.WW.Auth.Ldap</c> package so consuming apps register a provider with a single call.
/// </summary>
/// <remarks>
/// API-key DI wiring lives in <c>ZB.MOM.WW.Auth.ApiKeys</c>
/// (<c>ZB.MOM.WW.Auth.ApiKeys.DependencyInjection.ApiKeyServiceCollectionExtensions.AddZbApiKeyAuth</c>)
/// so that an LDAP-only consumer can reference this package without pulling in SQLite.
/// </remarks>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers LDAP authentication: binds and validates <see cref="LdapOptions"/> from the
/// configuration section at <paramref name="sectionPath"/>, and registers
/// <see cref="ILdapAuthService"/>.
/// </summary>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="sectionPath">Path of the configuration section holding the LDAP options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbLdapAuth(
this IServiceCollection services,
IConfiguration config,
string sectionPath)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
services.Configure<LdapOptions>(config.GetSection(sectionPath));
// Fail fast at startup on a misconfigured directory rather than on first login.
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
// LdapAuthService is stateless: it holds only a snapshot of LdapOptions and a stateless
// connection factory, and opens/disposes a connection per call. It is not IDisposable.
// Singleton is correct; TryAdd mirrors the pattern in AddZbApiKeyAuth (idempotency).
services.TryAddSingleton<ILdapAuthService>(sp =>
new LdapAuthService(sp.GetRequiredService<IOptions<LdapOptions>>().Value));
return services;
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Auth.AspNetCore</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>ASP.NET Core DI helpers, cookie defaults, and claim mappings for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
</ItemGroup>
<ItemGroup>
<!--
Microsoft.AspNetCore.App is a shared framework, not a NuGet package. It brings in
cookie authentication (Microsoft.AspNetCore.Authentication.Cookies), authorization,
and the Microsoft.Extensions.* surface (Configuration.Abstractions, Options,
DependencyInjection.Abstractions) used by the DI helpers below. There is no net10
standalone NuGet package for cookie auth, so referencing the shared framework is the
supported path.
-->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,40 @@
using System.Security.Claims;
namespace ZB.MOM.WW.Auth.AspNetCore;
/// <summary>
/// Canonical claim-type constants used across ZB.MOM.WW authentication. Centralising the
/// strings here keeps claim issuance (LDAP/API-key sign-in) and claim consumption
/// (authorization policies, role checks) in agreement on exactly one spelling per concept.
/// </summary>
/// <remarks>
/// <see cref="Name"/> and <see cref="Role"/> deliberately alias the framework's
/// <see cref="ClaimTypes.Name"/> and <see cref="ClaimTypes.Role"/> URIs so that ASP.NET
/// Core's built-in <see cref="ClaimsPrincipal.Identity"/> name resolution and
/// <c>[Authorize(Roles = ...)]</c> / <see cref="ClaimsPrincipal.IsInRole(string)"/> checks
/// work without bespoke configuration. The remaining claim types are app-specific and use
/// stable, short <c>zb:</c>-prefixed names that will not collide with the framework URIs.
/// </remarks>
public static class ZbClaimTypes
{
/// <summary>
/// The principal's name claim. Aliases <see cref="ClaimTypes.Name"/> so the framework
/// populates <see cref="System.Security.Principal.IIdentity.Name"/> from it.
/// </summary>
public const string Name = ClaimTypes.Name;
/// <summary>
/// A role claim. Aliases <see cref="ClaimTypes.Role"/> so <c>[Authorize(Roles = ...)]</c>
/// and <see cref="ClaimsPrincipal.IsInRole(string)"/> resolve against it by default.
/// </summary>
public const string Role = ClaimTypes.Role;
/// <summary>Human-friendly display name (distinct from the login <see cref="Name"/>).</summary>
public const string DisplayName = "zb:displayname";
/// <summary>The directory/login username the principal authenticated as.</summary>
public const string Username = "zb:username";
/// <summary>The identifier of the scope (site/area) the principal's roles apply within.</summary>
public const string ScopeId = "zb:scopeid";
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
namespace ZB.MOM.WW.Auth.AspNetCore;
/// <summary>
/// Applies the hardened cookie-authentication defaults shared by ZB.MOM.WW apps:
/// HTTP-only, <see cref="SameSiteMode.Strict"/>, sliding expiration, a caller-supplied idle
/// timeout, and a configurable HTTPS requirement.
/// </summary>
/// <remarks>
/// The cookie <em>name</em> is intentionally left untouched: each app owns its own cookie name
/// (so two apps on the same host do not clobber each other's session), and the caller sets it
/// when configuring the cookie scheme.
/// </remarks>
public static class ZbCookieDefaults
{
/// <summary>
/// Default idle timeout used when a caller does not supply one. After this much inactivity
/// the (sliding) session cookie expires and the principal must re-authenticate.
/// </summary>
public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(30);
/// <summary>
/// Applies the hardened defaults to <paramref name="options"/>.
/// </summary>
/// <param name="options">The cookie-authentication options to mutate.</param>
/// <param name="requireHttps">
/// When <see langword="true"/> (the default), the cookie is only ever sent over HTTPS
/// (<see cref="CookieSecurePolicy.Always"/>). Set to <see langword="false"/> only for local
/// development over plain HTTP (<see cref="CookieSecurePolicy.SameAsRequest"/>: Secure is
/// still set when the current request is HTTPS, which is safer than <c>None</c>).
/// </param>
/// <param name="idleTimeout">
/// The sliding idle timeout. Defaults to <see cref="DefaultIdleTimeout"/> when not specified.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
public static void Apply(
CookieAuthenticationOptions options,
bool requireHttps = true,
TimeSpan? idleTimeout = null)
{
ArgumentNullException.ThrowIfNull(options);
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = requireHttps
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
options.SlidingExpiration = true;
options.ExpireTimeSpan = idleTimeout ?? DefaultIdleTimeout;
}
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
/// <summary>
/// A single LDAP search result entry: its DN and a flat attribute bag.
/// </summary>
internal sealed record LdapSearchEntry(
string Dn,
IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
/// <summary>
/// Abstraction over a single LDAP connection. Allows unit-testing
/// <c>LdapAuthService</c> without a live directory server.
/// </summary>
internal interface ILdapConnection : IDisposable
{
/// <summary>Opens (and optionally upgrades to TLS) a connection to the given host.</summary>
void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs);
/// <summary>Binds with the supplied DN and password. Throws <c>LdapException</c> on bad credentials.</summary>
void Bind(string dn, string password);
/// <summary>Executes a subtree search and returns all matching entries.</summary>
IReadOnlyList<LdapSearchEntry> Search(string searchBase, string filter, IReadOnlyList<string> attributes);
}
/// <summary>Factory that produces <see cref="ILdapConnection"/> instances.</summary>
internal interface ILdapConnectionFactory
{
ILdapConnection Create();
}
@@ -0,0 +1,121 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary>
/// RFC 4515 LDAP filter escaping and RFC 4514 DN attribute-value escaping utilities.
/// </summary>
internal static class LdapEscaping
{
/// <summary>
/// Escapes a string for safe use inside an RFC 4515 LDAP search filter assertion value.
/// Escapes (in order): backslash, asterisk, left-paren, right-paren, NUL.
/// </summary>
public static string Filter(string input)
{
if (string.IsNullOrEmpty(input))
return input;
// Backslash must be escaped first so we don't double-escape subsequent replacements.
return input
.Replace("\\", @"\5c")
.Replace("*", @"\2a")
.Replace("(", @"\28")
.Replace(")", @"\29")
.Replace("\0", @"\00");
}
/// <summary>
/// Escapes a string for safe use as an RFC 4514 DN attribute value.
/// Escapes: , + " \ &lt; &gt; ; (with a leading backslash);
/// also escapes a leading '#' and leading/trailing space.
/// </summary>
public static string Dn(string input)
{
if (string.IsNullOrEmpty(input))
return input;
var sb = new System.Text.StringBuilder(input.Length + 8);
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
switch (c)
{
case ',':
case '+':
case '"':
case '\\':
case '<':
case '>':
case ';':
sb.Append('\\').Append(c);
break;
case '#' when i == 0:
sb.Append(@"\#");
break;
case ' ' when i == 0 || i == input.Length - 1:
sb.Append(@"\ ");
break;
default:
sb.Append(c);
break;
}
}
return sb.ToString();
}
/// <summary>
/// Extracts the value of the first RDN from a DN, e.g.
/// <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>. The scan is RFC 4514 escape-aware:
/// a backslash-escaped <c>,</c> inside the RDN value does not terminate it, and recognised
/// escape sequences — single-character (<c>\,</c> <c>\\</c> …) and two-digit hex
/// (<c>\2c</c>) — are unescaped, so a group CN that legitimately contains a comma is
/// returned intact (Security-013). A string with no <c>=</c> is returned unchanged.
/// </summary>
public static string FirstRdnValue(string dn)
{
if (string.IsNullOrEmpty(dn))
return dn;
var equalsIndex = dn.IndexOf('=');
if (equalsIndex < 0)
return dn;
var valueStart = equalsIndex + 1;
var sb = new System.Text.StringBuilder(dn.Length - valueStart);
for (var i = valueStart; i < dn.Length; i++)
{
var c = dn[i];
if (c == '\\' && i + 1 < dn.Length)
{
var next = dn[i + 1];
// RFC 4514 hex escape: \XX (two hex digits).
if (i + 2 < dn.Length && IsHexDigit(next) && IsHexDigit(dn[i + 2]))
{
sb.Append((char)Convert.ToInt32(dn.Substring(i + 1, 2), 16));
i += 2;
}
else
{
// Single-character escape (e.g. \, \+ \\ \" \; etc.) — emit the
// escaped character literally and skip the backslash.
sb.Append(next);
i += 1;
}
continue;
}
if (c == ',')
{
// Unescaped comma terminates the first RDN.
break;
}
sb.Append(c);
}
return sb.ToString();
}
private static bool IsHexDigit(char c)
=> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
@@ -0,0 +1,105 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
/// <summary>
/// Production <see cref="ILdapConnection"/> backed by <c>Novell.Directory.Ldap.LdapConnection</c>.
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
/// </summary>
internal sealed class NovellLdapConnection : ILdapConnection
{
private readonly LdapConnection _conn = new();
private bool _disposed;
/// <inheritdoc/>
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
{
ApplyTimeout(timeoutMs);
// LDAPS: TLS is negotiated at the TCP-connection level.
if (transport == LdapTransport.Ldaps)
_conn.SecureSocketLayer = true;
_conn.Connect(host, port);
// StartTLS: connect plaintext first, then upgrade inside the session.
if (transport == LdapTransport.StartTls)
{
_conn.StartTls();
if (!_conn.Tls)
throw new InvalidOperationException(
"StartTLS upgrade did not produce an encrypted session.");
}
}
/// <inheritdoc/>
public void Bind(string dn, string password)
=> _conn.Bind(dn, password);
/// <inheritdoc/>
public IReadOnlyList<LdapSearchEntry> Search(
string searchBase,
string filter,
IReadOnlyList<string> attributes)
{
var results = _conn.Search(
searchBase,
LdapConnection.ScopeSub,
filter,
attributes.ToArray(),
typesOnly: false);
var entries = new List<LdapSearchEntry>();
while (results.HasMore())
{
var entry = results.Next();
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (LdapAttribute attr in entry.GetAttributeSet())
{
attrs[attr.Name] = attr.StringValueArray.ToList();
}
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
}
return entries;
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_conn.Connected)
_conn.Disconnect();
_conn.Dispose();
}
// -------------------------------------------------------------------------
private void ApplyTimeout(int timeoutMs)
{
if (timeoutMs <= 0)
return;
_conn.ConnectionTimeout = timeoutMs;
// SearchConstraints.TimeLimit is per-operation (ms). SearchConstraints getter
// returns the live LdapSearchConstraints object (read-only property), but
// TimeLimit is mutable in-place via the base LdapConstraints type.
// We then assign it back through the writable Constraints property so
// Novell picks up the change — mirrors ScadaBridge idiom.
var constraints = _conn.SearchConstraints;
constraints.TimeLimit = timeoutMs;
_conn.Constraints = constraints;
}
}
/// <summary>Factory that produces fresh <see cref="NovellLdapConnection"/> instances.</summary>
internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory
{
public ILdapConnection Create() => new NovellLdapConnection();
}
@@ -0,0 +1,245 @@
using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap;
/// <summary>
/// Authenticates a user against an LDAP directory using the bind-then-search idiom:
/// bind as the service account, search for the user entry, then re-bind as the user
/// to verify their password. Connection mechanics are delegated to an
/// <see cref="ILdapConnection"/> so the logic is unit-testable without a live server.
/// </summary>
/// <remarks>
/// Fully fail-closed: authentication never throws to the caller — every error, expected
/// or unexpected, is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>.
/// A success is only returned for a user that resolved to exactly one entry, whose password
/// verified, AND who has at least one group (zero groups is never admitted as success).
/// Service-account bind failures (<see cref="LdapAuthFailure.ServiceAccountBindFailed"/>) are
/// kept distinct from end-user bind failures (<see cref="LdapAuthFailure.BadCredentials"/>) so
/// a system misconfiguration is not mistaken for bad user input.
/// </remarks>
public sealed class LdapAuthService : ILdapAuthService
{
private readonly LdapOptions _options;
private readonly ILdapConnectionFactory _connectionFactory;
/// <summary>
/// Production constructor: binds against a live directory via the real
/// Novell-backed connection factory.
/// </summary>
public LdapAuthService(LdapOptions options)
: this(options, new NovellLdapConnectionFactory())
{
}
/// <summary>
/// Test/seam constructor: accepts an injected <see cref="ILdapConnectionFactory"/>
/// so the bind/search logic can be exercised without a live directory. Internal
/// because the connection seam is an implementation detail.
/// </summary>
internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory)
{
_options = options;
_connectionFactory = connectionFactory;
}
/// <inheritdoc />
/// <remarks>
/// Fail-closed contract: this method never throws to the caller. Every stage of the
/// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected —
/// is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>. A
/// <c>Succeeded == true</c> result is only ever returned when the user resolved to exactly
/// one entry, their password verified, AND at least one group was extracted.
/// </remarks>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
// The Novell calls behind ILdapConnection are synchronous and blocking, so the token
// cannot interrupt an in-progress operation; it is only observed here at entry, before
// any work begins.
ct.ThrowIfCancellationRequested();
return Task.FromResult(Authenticate(username, password));
}
private LdapAuthResult Authenticate(string username, string password)
{
// 1. Feature gate: an explicitly disabled provider must never touch the network.
if (!_options.Enabled)
return LdapAuthResult.Fail(LdapAuthFailure.Disabled);
// 2. Reject a missing username before anything else — guarding here means a null
// username can't NRE into the catch-all and surface as a system-side failure.
if (string.IsNullOrWhiteSpace(username))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
// 3. Normalise once, up front, so the same canonical value flows into the LDAP
// filter and the returned result (avoids two identities for one person).
username = username.Trim();
// The whole flow runs inside an outer fail-closed guard: a StageFailure carries an
// already-mapped failure out of a stage, and any OTHER unexpected exception defaults to
// the most conservative system-side bucket. Either way the caller gets a structured result.
try
{
using var conn = _connectionFactory.Create();
// 4. Open the connection (transport/TLS handling lives in the adapter). The
// per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here.
// A failure to connect/upgrade means the directory is unreachable — a
// system-side fault, not the user's, so map it to ServiceAccountBindFailed.
// NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value;
// ServiceAccountBindFailed is the closest system-side bucket. A future
// Abstractions change could add DirectoryUnavailable to disambiguate.
try
{
conn.Connect(_options.Server, _options.Port, _options.Transport, _options.AllowInsecure, _options.ConnectionTimeoutMs);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 5. Service-account bind so we can search for the user's DN. A bind failure
// here is a service-account misconfiguration — DISTINCT from a user-credential
// failure — so it maps to ServiceAccountBindFailed.
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 6. Search for the user entry by the configured username attribute. A search
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
// closest system-side bucket until a DirectoryUnavailable value exists.
IReadOnlyList<LdapSearchEntry> entries;
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
}
// 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// We never attempt a user bind against an ambiguous DN.
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2)
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
// 8. User bind: re-bind as the resolved DN to verify the password. A bind failure
// here is the end user's bad credentials.
try
{
conn.Bind(entry.Dn, password);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.BadCredentials);
}
// 9. Group extraction. Fail closed: an empty/missing group set, or any error while
// extracting groups, is a GroupLookupFailed — never a zero-group success.
IReadOnlyList<string> groups;
try
{
groups = ExtractGroups(entry);
}
catch
{
throw new StageFailure(LdapAuthFailure.GroupLookupFailed);
}
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
// 10. Success — and only here, with a verified password and >= 1 group.
var displayName = ExtractDisplayName(entry, username);
return LdapAuthResult.Success(username, displayName, groups);
}
catch (StageFailure stage)
{
// A stage mapped its own failure; surface it as a structured result.
return LdapAuthResult.Fail(stage.Failure);
}
catch
{
// Belt-and-braces: ANY unexpected exception fails closed to the most conservative
// system-side bucket rather than propagating to the caller.
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
}
}
/// <summary>
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
/// out of a stage to the single fail-closed catch site. Never escapes this type.
/// </summary>
private sealed class StageFailure : Exception
{
public StageFailure(LdapAuthFailure failure) => Failure = failure;
public LdapAuthFailure Failure { get; }
}
/// <summary>
/// Builds the distinct attribute list requested from the directory. The display-name
/// and group attributes are de-duplicated so we never request the same attribute twice
/// when an operator configures them to the same value.
/// </summary>
private IReadOnlyList<string> BuildSearchAttributes()
{
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
return new[] { _options.DisplayNameAttribute };
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
}
/// <summary>
/// Returns the first value of the configured display-name attribute, falling back to
/// the (already normalised) username when the directory entry has no such attribute.
/// </summary>
private string ExtractDisplayName(LdapSearchEntry entry, string username)
{
if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0)
return values[0];
return username;
}
/// <summary>
/// Extracts group short names from the configured group attribute. Each value is a
/// group DN (e.g. <c>cn=Engineers,ou=g,dc=x</c>); the first RDN's value is returned
/// (e.g. <c>Engineers</c>), RFC 4514 escape-aware so an escaped comma in the CN is
/// preserved rather than truncating the name.
/// </summary>
private IReadOnlyList<string> ExtractGroups(LdapSearchEntry entry)
{
if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0)
return Array.Empty<string>();
var groups = new List<string>(values.Count);
foreach (var value in values)
groups.Add(ToGroupShortName(value));
return groups;
}
/// <summary>
/// Yields a group's short name from its DN by returning the value of the first RDN
/// (e.g. <c>cn=Engineers,ou=g,dc=x</c> → <c>Engineers</c>). The extraction is RFC 4514
/// escape-aware (<see cref="LdapEscaping.FirstRdnValue"/>), so a CN that legitimately
/// contains an escaped comma — <c>cn=Eng\,ineers,...</c> or <c>cn=A\2cB,...</c> — is
/// returned intact rather than truncated at the escaped comma. Values with no <c>=</c>
/// are returned unchanged.
/// </summary>
private static string ToGroupShortName(string groupDn)
=> LdapEscaping.FirstRdnValue(groupDn);
}
@@ -0,0 +1,63 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Auth.Ldap;
/// <summary>
/// Validates <see cref="LdapOptions"/> at startup so a misconfiguration fails fast at
/// boot with a clear, field-naming message — rather than surfacing later as an opaque
/// low-level error on the first real login attempt.
/// </summary>
/// <remarks>
/// Four conditions are enforced:
/// <list type="bullet">
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
/// <see cref="LdapOptions.AllowInsecure"/> is explicitly set (dev/test only);</item>
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
/// search runs against);</item>
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
/// would bind anonymously, defeating the search-then-bind authentication flow.</item>
/// </list>
/// </remarks>
public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, LdapOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.Transport)} is {nameof(LdapTransport.None)} (insecure/plaintext) " +
$"but {nameof(LdapOptions.AllowInsecure)} is false. Enable TLS " +
$"({nameof(LdapTransport.Ldaps)} or {nameof(LdapTransport.StartTls)}) " +
$"or set {nameof(LdapOptions.AllowInsecure)} for dev/test.");
}
if (string.IsNullOrWhiteSpace(options.Server))
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.Server)} is required but was empty or whitespace — " +
"set it to the LDAP server hostname or IP (e.g. \"ldap.example.com\").");
}
if (string.IsNullOrWhiteSpace(options.SearchBase))
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.SearchBase)} is required but was empty or whitespace — " +
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
}
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
"an empty value would bind anonymously. Set it to the service-account DN " +
"(e.g. \"cn=svc,dc=example,dc=com\").");
}
return ValidateOptionsResult.Success;
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Auth.Ldap</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>LDAP authentication service (GLAuth / Active Directory) for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.Ldap.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,349 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
{
private const string Pepper = "test-pepper-value";
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
private AuthSqliteConnectionFactory _factory = null!;
private SqliteAuthStoreMigrator _migrator = null!;
private SqliteApiKeyAdminStore _admin = null!;
private SqliteApiKeyStore _read = null!;
private SqliteApiKeyAuditStore _audit = null!;
private ApiKeyOptions _options = null!;
public Task InitializeAsync()
{
_factory = new AuthSqliteConnectionFactory(_dbPath);
_migrator = new SqliteAuthStoreMigrator(_factory);
_admin = new SqliteApiKeyAdminStore(_factory);
_read = new SqliteApiKeyStore(_factory);
_audit = new SqliteApiKeyAuditStore(_factory);
_options = new ApiKeyOptions { TokenPrefix = "mxgw", SqlitePath = _dbPath };
return Task.CompletedTask;
}
private ApiKeyAdminCommands BuildCommands(string? pepper = Pepper) => new(
_options,
_admin,
_audit,
new FakePepperProvider(pepper),
_migrator);
// --- init-db ---
[Fact]
public async Task InitDb_CreatesTables_AndAppendsAudit()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(remoteAddress: "10.0.0.1", CancellationToken.None);
// Tables exist: a create after init must succeed.
Assert.True(await TableExistsAsync("api_keys"));
Assert.True(await TableExistsAsync("api_key_audit"));
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
Assert.Single(recent, e => e.EventType == "init-db");
}
// --- create-key ---
[Fact]
public async Task CreateKey_ReturnsAssembledToken_KeyFindable_AndAuditAppended()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
CreateKeyResult result = await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read", "write"], StringComparer.Ordinal),
constraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
remoteAddress: "10.0.0.1",
CancellationToken.None);
Assert.Equal("key-1", result.KeyId);
Assert.StartsWith("mxgw_key-1_", result.Token);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(found);
// The returned token's secret matches what is stored (hash of parsed secret == stored hash).
string secret = ParseSecret(result.Token);
byte[] expected = ApiKeySecretHasher.Hash(secret, Pepper);
Assert.True(found!.SecretHash.SequenceEqual(expected));
// Exactly one create-key audit row.
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
Assert.Single(recent, e => e.EventType == "create-key");
}
[Fact]
public async Task CreateKey_PepperUnavailable_ReturnsNoTokenAndAppendsNoAudit()
{
ApiKeyAdminCommands commands = BuildCommands(pepper: null);
await new ApiKeyAdminCommands(_options, _admin, _audit, new FakePepperProvider(Pepper), _migrator)
.InitDbAsync(null, CancellationToken.None);
int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
await Assert.ThrowsAsync<InvalidOperationException>(() => commands.CreateKeyAsync(
"key-x",
"No Pepper",
new HashSet<string>(StringComparer.Ordinal),
constraintsJson: null,
remoteAddress: null,
CancellationToken.None));
// No key created, no audit appended.
Assert.Null(await _read.FindByKeyIdAsync("key-x", CancellationToken.None));
int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
Assert.Equal(auditCountBefore, auditCountAfter);
}
[Fact]
public async Task CreateKey_KeyIdContainsUnderscore_ThrowsArgumentException()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await Assert.ThrowsAsync<ArgumentException>(() => commands.CreateKeyAsync(
"a_b",
"Service A",
new HashSet<string>(StringComparer.Ordinal),
constraintsJson: null,
remoteAddress: null,
CancellationToken.None));
}
// --- list-keys ---
[Fact]
public async Task ListKeys_ReturnsCreatedKey_WithoutSecretMaterial()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
constraintsJson: null,
remoteAddress: null,
CancellationToken.None);
IReadOnlyList<ApiKeyListItem> keys = await commands.ListKeysAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(keys, k => k.KeyId == "key-1");
Assert.Equal("Service A", item.DisplayName);
Assert.Contains("read", item.Scopes);
Assert.Null(item.RevokedUtc);
// ApiKeyListItem has NO secret/hash member by construction (compile-time guarantee).
Assert.DoesNotContain(
typeof(ApiKeyListItem).GetProperties(),
p => p.Name.Contains("Hash", StringComparison.OrdinalIgnoreCase)
|| p.Name.Contains("Secret", StringComparison.OrdinalIgnoreCase));
}
// --- revoke-key ---
[Fact]
public async Task RevokeKey_DeactivatesKey_AndAppendsAudit()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
null,
null,
CancellationToken.None);
KeyActionResult result = await commands.RevokeKeyAsync("key-1", "10.0.0.1", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
Assert.Single(recent, e => e.EventType == "revoke-key");
}
// --- rotate-key ---
[Fact]
public async Task RotateKey_ReturnsNewToken_OldSecretFails_NewSecretWorks_AndAuditAppended()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
CreateKeyResult created = await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
null,
null,
CancellationToken.None);
string oldSecret = ParseSecret(created.Token);
CreateKeyResult rotated = await commands.RotateKeyAsync("key-1", "10.0.0.1", CancellationToken.None);
Assert.Equal("key-1", rotated.KeyId);
Assert.NotEqual(created.Token, rotated.Token);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(found);
// Old secret no longer verifies; new one does.
Assert.False(ApiKeySecretHasher.Verify(oldSecret, Pepper, found!.SecretHash));
Assert.True(ApiKeySecretHasher.Verify(ParseSecret(rotated.Token), Pepper, found.SecretHash));
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
Assert.Single(recent, e => e.EventType == "rotate-key");
}
[Fact]
public async Task RotateKey_UnknownKey_ReturnsFailureResult_AndAppendsAudit()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
CreateKeyResult result = await commands.RotateKeyAsync("missing", null, CancellationToken.None);
Assert.Null(result.Token);
// Auditing failed/not-found attempts is INTENTIONAL (security trail): exactly one rotate-key row.
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
int newAuditRows = recent.Count - auditCountBefore;
Assert.Equal(1, newAuditRows);
ApiKeyAuditEntry auditRow = recent.First(e => e.EventType == "rotate-key");
Assert.Equal("not-found", auditRow.Details);
}
[Fact]
public async Task RotateKey_PepperUnavailable_Throws_HashUnchanged_AndAppendsNoAudit()
{
// Arrange: create a key with a valid pepper.
ApiKeyAdminCommands setupCommands = BuildCommands(pepper: Pepper);
await setupCommands.InitDbAsync(null, CancellationToken.None);
await setupCommands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
constraintsJson: null,
remoteAddress: null,
CancellationToken.None);
ApiKeyRecord? before = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(before);
byte[] hashBefore = before!.SecretHash;
int auditCountBefore = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
// Act: rotate with no pepper available.
ApiKeyAdminCommands nopepper = BuildCommands(pepper: null);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
nopepper.RotateKeyAsync("key-1", null, CancellationToken.None));
// Assert: stored hash is unchanged.
ApiKeyRecord? after = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(after);
Assert.True(after!.SecretHash.SequenceEqual(hashBefore));
// Assert: no rotate-key audit row was appended (RequirePepper fires before any store/audit write).
int auditCountAfter = (await _audit.ListRecentAsync(50, CancellationToken.None)).Count;
Assert.Equal(auditCountBefore, auditCountAfter);
}
// --- delete-key ---
[Fact]
public async Task DeleteKey_OnlyWorksAfterRevoke_AndAppendsAudit()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
null,
null,
CancellationToken.None);
// Delete before revoke fails.
KeyActionResult beforeRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None);
Assert.False(beforeRevoke.Succeeded);
Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
await commands.RevokeKeyAsync("key-1", null, CancellationToken.None);
KeyActionResult afterRevoke = await commands.DeleteKeyAsync("key-1", null, CancellationToken.None);
Assert.True(afterRevoke.Succeeded);
Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
// Two delete-key audit rows (one failed attempt, one success) — each verb audits exactly once per call.
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
Assert.Equal(2, recent.Count(e => e.EventType == "delete-key"));
}
// --- helpers ---
private static string ParseSecret(string? token)
{
// token = "<prefix>_<keyId>_<secret>"; secret may contain underscores.
Assert.NotNull(token);
string[] parts = token!.Split('_', 3);
return parts[2];
}
private async Task<bool> TableExistsAsync(string tableName)
{
await using SqliteConnection connection =
await _factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name;";
command.Parameters.AddWithValue("$name", tableName);
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
return count > 0;
}
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
{
public string? GetPepper() => pepper;
}
public Task DisposeAsync()
{
SqliteConnection.ClearAllPools();
TryDelete(_dbPath);
TryDelete(_dbPath + "-wal");
TryDelete(_dbPath + "-shm");
return Task.CompletedTask;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort cleanup of the per-test temp database.
}
}
}
@@ -0,0 +1,156 @@
using ZB.MOM.WW.Auth.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public class ApiKeyParserTests
{
// --- basic happy path ---
[Fact]
public void TryParse_SimpleToken_ReturnsParsedKey()
{
var result = ApiKeyParser.TryParse("mxgw_alice_SECRET", "mxgw");
Assert.NotNull(result);
Assert.Equal("alice", result.KeyId);
Assert.Equal("SECRET", result.Secret);
}
[Fact]
public void TryParse_BearerPrefixCaseInsensitive_ReturnsParsedKey()
{
var result = ApiKeyParser.TryParse("Bearer mxgw_alice_SEC_RET", "mxgw");
Assert.NotNull(result);
Assert.Equal("alice", result.KeyId);
Assert.Equal("SEC_RET", result.Secret);
}
[Fact]
public void TryParse_BearerLowercase_ReturnsParsedKey()
{
var result = ApiKeyParser.TryParse("bearer mxgw_alice_SECRET", "mxgw");
Assert.NotNull(result);
Assert.Equal("alice", result.KeyId);
Assert.Equal("SECRET", result.Secret);
}
[Fact]
public void TryParse_SecretContainsUnderscores_SecretIsEverythingAfterFirstSplit()
{
var result = ApiKeyParser.TryParse("mxgw_k1_a_b_c", "mxgw");
Assert.NotNull(result);
Assert.Equal("k1", result.KeyId);
Assert.Equal("a_b_c", result.Secret);
}
// --- custom prefix ---
[Fact]
public void TryParse_CustomPrefix_Works()
{
var result = ApiKeyParser.TryParse("myapp_user42_s3cr3t", "myapp");
Assert.NotNull(result);
Assert.Equal("user42", result.KeyId);
Assert.Equal("s3cr3t", result.Secret);
}
[Fact]
public void TryParse_CustomPrefix_WithBearer()
{
var result = ApiKeyParser.TryParse("Bearer myapp_user42_s3cr3t", "myapp");
Assert.NotNull(result);
Assert.Equal("user42", result.KeyId);
Assert.Equal("s3cr3t", result.Secret);
}
// --- rejection cases ---
[Fact]
public void TryParse_WrongPrefix_ReturnsNull()
{
var result = ApiKeyParser.TryParse("zzz_a_b", "mxgw");
Assert.Null(result);
}
[Fact]
public void TryParse_NoDelimiters_ReturnsNull()
{
var result = ApiKeyParser.TryParse("nodelims", "mxgw");
Assert.Null(result);
}
[Fact]
public void TryParse_NullInput_ReturnsNull()
{
var result = ApiKeyParser.TryParse(null, "mxgw");
Assert.Null(result);
}
[Fact]
public void TryParse_EmptyInput_ReturnsNull()
{
var result = ApiKeyParser.TryParse("", "mxgw");
Assert.Null(result);
}
[Fact]
public void TryParse_WhitespaceInput_ReturnsNull()
{
var result = ApiKeyParser.TryParse(" ", "mxgw");
Assert.Null(result);
}
[Fact]
public void TryParse_OnlyPrefix_NoKeyIdOrSecret_ReturnsNull()
{
// "mxgw_" — prefix present but no key id segment
var result = ApiKeyParser.TryParse("mxgw_", "mxgw");
Assert.Null(result);
}
[Fact]
public void TryParse_PrefixAndKeyIdButNoSecret_ReturnsNull()
{
// "mxgw_alice" — no second underscore after key id
var result = ApiKeyParser.TryParse("mxgw_alice", "mxgw");
Assert.Null(result);
}
[Fact]
public void TryParse_PrefixAndUnderscoreButEmptySecret_ReturnsNull()
{
// "mxgw_alice_" — secret is empty
var result = ApiKeyParser.TryParse("mxgw_alice_", "mxgw");
Assert.Null(result);
}
// --- generator↔parser round-trip ---
[Fact]
public void TryParse_RealGeneratedSecret_RoundTripsKeyIdAndFullSecret()
{
// ApiKeySecretGenerator produces URL-safe base64 which may contain '_'.
// The parser must preserve the full secret even when it contains underscores.
string secret = ApiKeySecretGenerator.NewSecret();
string token = $"mxgw_someid_{secret}";
var result = ApiKeyParser.TryParse(token, "mxgw");
Assert.NotNull(result);
Assert.Equal("someid", result!.KeyId);
Assert.Equal(secret, result.Secret);
}
}
@@ -0,0 +1,68 @@
using ZB.MOM.WW.Auth.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public class ApiKeySecretGeneratorTests
{
[Fact]
public void NewSecret_ReturnsNonEmpty()
{
var secret = ApiKeySecretGenerator.NewSecret();
Assert.NotEmpty(secret);
}
[Fact]
public void NewSecret_TwoCallsDiffer()
{
var first = ApiKeySecretGenerator.NewSecret();
var second = ApiKeySecretGenerator.NewSecret();
Assert.NotEqual(first, second);
}
[Fact]
public void NewSecret_DecodesToThirtyTwoBytes()
{
var secret = ApiKeySecretGenerator.NewSecret();
// Restore URL-safe base64 to standard before decoding
string standard = secret.Replace('-', '+').Replace('_', '/');
// Add padding if needed
int pad = standard.Length % 4;
if (pad == 2) standard += "==";
else if (pad == 3) standard += "=";
byte[] bytes = Convert.FromBase64String(standard);
Assert.Equal(32, bytes.Length);
}
[Fact]
public void NewSecret_IsUrlSafe_NoPlus()
{
// Run many iterations to make collisions with '+' unlikely to be missed
for (int i = 0; i < 200; i++)
{
Assert.DoesNotContain('+', ApiKeySecretGenerator.NewSecret());
}
}
[Fact]
public void NewSecret_IsUrlSafe_NoSlash()
{
for (int i = 0; i < 200; i++)
{
Assert.DoesNotContain('/', ApiKeySecretGenerator.NewSecret());
}
}
[Fact]
public void NewSecret_IsUrlSafe_NoPaddingEquals()
{
for (int i = 0; i < 200; i++)
{
Assert.DoesNotContain('=', ApiKeySecretGenerator.NewSecret());
}
}
}
@@ -0,0 +1,99 @@
using ZB.MOM.WW.Auth.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public class ApiKeySecretHasherTests
{
private const string Secret = "mysecret";
private const string Pepper = "mypepper";
// --- Hash determinism ---
[Fact]
public void Hash_SameInputs_ProducesIdenticalHashes()
{
byte[] first = ApiKeySecretHasher.Hash(Secret, Pepper);
byte[] second = ApiKeySecretHasher.Hash(Secret, Pepper);
Assert.Equal(first, second);
}
[Fact]
public void Hash_DifferentPepper_ProducesDifferentHash()
{
byte[] withPepper1 = ApiKeySecretHasher.Hash(Secret, "pepper1");
byte[] withPepper2 = ApiKeySecretHasher.Hash(Secret, "pepper2");
Assert.NotEqual(withPepper1, withPepper2);
}
[Fact]
public void Hash_DifferentSecret_ProducesDifferentHash()
{
byte[] hash1 = ApiKeySecretHasher.Hash("secret1", Pepper);
byte[] hash2 = ApiKeySecretHasher.Hash("secret2", Pepper);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void Hash_ReturnsThirtyTwoBytes()
{
// HMAC-SHA256 output is 256 bits = 32 bytes
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
Assert.Equal(32, hash.Length);
}
// --- Verify happy path ---
[Fact]
public void Verify_CorrectSecretAndPepper_ReturnsTrue()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
Assert.True(ApiKeySecretHasher.Verify(Secret, Pepper, hash));
}
[Fact]
public void Verify_WrongSecret_ReturnsFalse()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
Assert.False(ApiKeySecretHasher.Verify("wrongsecret", Pepper, hash));
}
[Fact]
public void Verify_WrongPepper_ReturnsFalse()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
Assert.False(ApiKeySecretHasher.Verify(Secret, "wrongpepper", hash));
}
// --- Constant-time: length mismatch must not throw ---
[Fact]
public void Verify_HashOfDifferentLength_ReturnsFalseWithoutThrowing()
{
// A hash of a completely different length — FixedTimeEquals must handle it
// without throwing and return false.
byte[] shortHash = [1, 2, 3];
var exception = Record.Exception(() => ApiKeySecretHasher.Verify(Secret, Pepper, shortHash));
Assert.Null(exception);
Assert.False(ApiKeySecretHasher.Verify(Secret, Pepper, shortHash));
}
[Fact]
public void Verify_EmptyHash_ReturnsFalseWithoutThrowing()
{
byte[] emptyHash = [];
var exception = Record.Exception(() => ApiKeySecretHasher.Verify(Secret, Pepper, emptyHash));
Assert.Null(exception);
Assert.False(ApiKeySecretHasher.Verify(Secret, Pepper, emptyHash));
}
}
@@ -0,0 +1,200 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public class ApiKeyServiceCollectionExtensionsTests
{
private const string ApiKeySection = "Auth:ApiKeys";
private const string PepperSecretName = "ApiKeyPepper";
private const string PepperValue = "super-secret-pepper-value";
private static IConfiguration BuildConfiguration(string sqlitePath, bool runMigrationsOnStartup = false) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{ApiKeySection}:TokenPrefix"] = "mxgw",
[$"{ApiKeySection}:SqlitePath"] = sqlitePath,
[$"{ApiKeySection}:PepperSecretName"] = PepperSecretName,
[$"{ApiKeySection}:RunMigrationsOnStartup"] = runMigrationsOnStartup ? "true" : "false",
// The pepper itself lives at the top level under the configured secret name.
[PepperSecretName] = PepperValue,
})
.Build();
private static string TempSqlitePath() =>
Path.Combine(Path.GetTempPath(), $"zbauth-test-{Guid.NewGuid():N}.db");
[Fact]
public void AddZbApiKeyAuth_ResolvesVerifier()
{
IConfiguration config = BuildConfiguration(TempSqlitePath());
var services = new ServiceCollection();
services.AddZbApiKeyAuth(config, ApiKeySection);
using ServiceProvider provider = services.BuildServiceProvider();
var verifier = provider.GetRequiredService<IApiKeyVerifier>();
Assert.NotNull(verifier);
}
[Fact]
public void AddZbApiKeyAuth_ResolvesAllStores()
{
IConfiguration config = BuildConfiguration(TempSqlitePath());
var services = new ServiceCollection();
services.AddZbApiKeyAuth(config, ApiKeySection);
using ServiceProvider provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetRequiredService<IApiKeyStore>());
Assert.NotNull(provider.GetRequiredService<IApiKeyAdminStore>());
Assert.NotNull(provider.GetRequiredService<IApiKeyAuditStore>());
}
[Fact]
public void AddZbApiKeyAuth_BindsOptionsFromSection()
{
string sqlitePath = TempSqlitePath();
IConfiguration config = BuildConfiguration(sqlitePath);
var services = new ServiceCollection();
services.AddZbApiKeyAuth(config, ApiKeySection);
using ServiceProvider provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<ApiKeyOptions>>();
Assert.Equal("mxgw", options.Value.TokenPrefix);
Assert.Equal(sqlitePath, options.Value.SqlitePath);
Assert.Equal(PepperSecretName, options.Value.PepperSecretName);
}
[Fact]
public void AddZbApiKeyAuth_PepperProviderReturnsConfiguredPepper()
{
IConfiguration config = BuildConfiguration(TempSqlitePath());
var services = new ServiceCollection();
services.AddZbApiKeyAuth(config, ApiKeySection);
using ServiceProvider provider = services.BuildServiceProvider();
var pepperProvider = provider.GetRequiredService<IApiKeyPepperProvider>();
Assert.IsType<ConfigurationApiKeyPepperProvider>(pepperProvider);
Assert.Equal(PepperValue, pepperProvider.GetPepper());
}
[Fact]
public void ConfigurationApiKeyPepperProvider_ReturnsNull_WhenSecretNameUnset()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var options = Options.Create(new ApiKeyOptions { PepperSecretName = "" });
var provider = new ConfigurationApiKeyPepperProvider(config, options);
Assert.Null(provider.GetPepper());
}
[Fact]
public void ConfigurationApiKeyPepperProvider_ReturnsNull_WhenValueAbsent()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var options = Options.Create(new ApiKeyOptions { PepperSecretName = "Missing" });
var provider = new ConfigurationApiKeyPepperProvider(config, options);
Assert.Null(provider.GetPepper());
}
[Fact]
public async Task AddZbApiKeyAuth_MigrationHostedService_CreatesSchemaOnStartup()
{
string sqlitePath = TempSqlitePath();
try
{
IConfiguration config = BuildConfiguration(sqlitePath, runMigrationsOnStartup: true);
var services = new ServiceCollection();
services.AddZbApiKeyAuth(config, ApiKeySection);
await using ServiceProvider provider = services.BuildServiceProvider();
// Find the ApiKeyMigrationHostedService among all registered IHostedService instances.
var hostedServices = provider.GetServices<IHostedService>().ToList();
IHostedService? migrationService = hostedServices
.FirstOrDefault(s => s.GetType().Name == "ApiKeyMigrationHostedService");
Assert.NotNull(migrationService);
await migrationService!.StartAsync(CancellationToken.None);
// Verify the api_keys table was created by the migration.
string connectionString = new SqliteConnectionStringBuilder
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadOnly,
}.ToString();
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'api_keys';
""";
long tableCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
Assert.Equal(1L, tableCount);
}
finally
{
if (File.Exists(sqlitePath))
File.Delete(sqlitePath);
}
}
[Fact]
public async Task AddZbApiKeyAuth_MigrationHostedService_SkipsMigration_WhenRunMigrationsOnStartupFalse()
{
string sqlitePath = TempSqlitePath();
try
{
IConfiguration config = BuildConfiguration(sqlitePath, runMigrationsOnStartup: false);
var services = new ServiceCollection();
services.AddZbApiKeyAuth(config, ApiKeySection);
await using ServiceProvider provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>().ToList();
IHostedService? migrationService = hostedServices
.FirstOrDefault(s => s.GetType().Name == "ApiKeyMigrationHostedService");
Assert.NotNull(migrationService);
// StartAsync should complete without creating the database file.
await migrationService!.StartAsync(CancellationToken.None);
Assert.False(File.Exists(sqlitePath),
"Migration should not run when RunMigrationsOnStartup is false.");
}
finally
{
if (File.Exists(sqlitePath))
File.Delete(sqlitePath);
}
}
}
@@ -0,0 +1,285 @@
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public class ApiKeyVerifierTests
{
private const string TokenPrefix = "mxgw";
private const string Pepper = "test-pepper";
private const string KeyId = "abc123";
private const string Secret = "supersecretvalue";
private const string DisplayName = "Test Key";
private const string ConstraintsJson = """{"ipAllow":["10.0.0.0/8"]}""";
private static readonly IReadOnlySet<string> Scopes =
new HashSet<string> { "read", "write" };
private static string Header(string keyId, string secret) =>
$"{TokenPrefix}_{keyId}_{secret}";
private static ApiKeyRecord BuildRecord(
byte[] secretHash,
DateTimeOffset? revokedUtc = null) => new(
KeyId: KeyId,
KeyPrefix: TokenPrefix,
SecretHash: secretHash,
DisplayName: DisplayName,
Scopes: Scopes,
ConstraintsJson: ConstraintsJson,
CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null,
RevokedUtc: revokedUtc);
private static ApiKeyVerifier BuildVerifier(
FakeApiKeyStore store,
FakePepperProvider pepperProvider) =>
new(new ApiKeyOptions { TokenPrefix = TokenPrefix }, store, pepperProvider);
// --- MissingOrMalformed ---
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("garbage")]
[InlineData("wrongprefix_abc123_secret")]
public async Task VerifyAsync_MissingOrMalformedHeader_ReturnsMissingOrMalformed(string? header)
{
var store = new FakeApiKeyStore();
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
ApiKeyVerification result = await verifier.VerifyAsync(header!, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
Assert.Null(result.Identity);
Assert.False(store.MarkUsedCalled);
}
// --- PepperUnavailable ---
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task VerifyAsync_PepperUnavailable_ReturnsPepperUnavailable(string? pepper)
{
var store = new FakeApiKeyStore();
var verifier = BuildVerifier(store, new FakePepperProvider(pepper));
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
Assert.Null(result.Identity);
Assert.False(store.MarkUsedCalled);
}
[Fact]
public async Task VerifyAsync_PepperUnavailable_DoesNotQueryStore()
{
var store = new FakeApiKeyStore();
var verifier = BuildVerifier(store, new FakePepperProvider(null));
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(store.FindByKeyIdCalled);
}
// --- KeyNotFound ---
[Fact]
public async Task VerifyAsync_KeyNotFound_ReturnsKeyNotFound()
{
var store = new FakeApiKeyStore { Record = null };
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
Assert.Null(result.Identity);
Assert.False(store.MarkUsedCalled);
}
// --- KeyRevoked ---
[Fact]
public async Task VerifyAsync_RevokedKey_ReturnsKeyRevoked()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore
{
Record = BuildRecord(hash, revokedUtc: DateTimeOffset.UtcNow),
};
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
Assert.Null(result.Identity);
Assert.False(store.MarkUsedCalled);
}
// --- SecretMismatch ---
[Fact]
public async Task VerifyAsync_WrongSecret_ReturnsSecretMismatch()
{
// Record's hash is built from a DIFFERENT secret with the test pepper.
byte[] hash = ApiKeySecretHasher.Hash("a-different-secret", Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
Assert.Null(result.Identity);
Assert.False(store.MarkUsedCalled);
}
// --- Success ---
[Fact]
public async Task VerifyAsync_ValidKey_ReturnsSuccessWithIdentity()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Null(result.Failure);
Assert.NotNull(result.Identity);
Assert.Equal(KeyId, result.Identity!.KeyId);
Assert.Equal(DisplayName, result.Identity.DisplayName);
Assert.Equal(Scopes, result.Identity.Scopes);
Assert.Equal(ConstraintsJson, result.Identity.Constraints);
}
[Fact]
public async Task VerifyAsync_ValidKey_MarksKeyUsed()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.True(store.MarkUsedCalled);
Assert.Equal(KeyId, store.MarkUsedKeyId);
}
[Fact]
public async Task VerifyAsync_ValidKey_UsesInjectedTimeProviderForMarkUsed()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero));
var verifier = new ApiKeyVerifier(
new ApiKeyOptions { TokenPrefix = TokenPrefix },
store,
new FakePepperProvider(Pepper),
fakeTime);
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.Equal(fakeTime.Now, store.MarkUsedWhenUtc);
}
[Fact]
public async Task VerifyAsync_ValidKey_DoesNotLeakSecretInIdentity()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
string identityText = result.Identity!.ToString();
Assert.DoesNotContain(Secret, identityText, StringComparison.Ordinal);
Assert.DoesNotContain(Pepper, identityText, StringComparison.Ordinal);
Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
}
// --- Cancellation ---
[Fact]
public async Task VerifyAsync_AlreadyCancelled_Throws()
{
var store = new FakeApiKeyStore();
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => verifier.VerifyAsync(Header(KeyId, Secret), cts.Token));
Assert.False(store.MarkUsedCalled);
}
// --- Bearer scheme acceptance (sanity) ---
[Fact]
public async Task VerifyAsync_BearerPrefixedValidKey_Succeeds()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
ApiKeyVerification result =
await verifier.VerifyAsync($"Bearer {Header(KeyId, Secret)}", CancellationToken.None);
Assert.True(result.Succeeded);
}
// --- Fakes ---
private sealed class FakeApiKeyStore : IApiKeyStore
{
public ApiKeyRecord? Record { get; set; }
public bool FindByKeyIdCalled { get; private set; }
public bool MarkUsedCalled { get; private set; }
public string? MarkUsedKeyId { get; private set; }
public DateTimeOffset? MarkUsedWhenUtc { get; private set; }
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
{
FindByKeyIdCalled = true;
return Task.FromResult(Record);
}
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct) =>
throw new NotSupportedException("Verifier must use FindByKeyIdAsync to discriminate revoked keys.");
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
MarkUsedCalled = true;
MarkUsedKeyId = keyId;
MarkUsedWhenUtc = whenUtc;
return Task.CompletedTask;
}
}
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
{
public string? GetPepper() => pepper;
}
private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider
{
public DateTimeOffset Now { get; } = now;
public override DateTimeOffset GetUtcNow() => Now;
}
}
@@ -0,0 +1,274 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
private AuthSqliteConnectionFactory _factory = null!;
private SqliteApiKeyAdminStore _admin = null!;
private SqliteApiKeyStore _read = null!;
private SqliteApiKeyAuditStore _audit = null!;
public async Task InitializeAsync()
{
_factory = new AuthSqliteConnectionFactory(_dbPath);
await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
_admin = new SqliteApiKeyAdminStore(_factory);
_read = new SqliteApiKeyStore(_factory);
_audit = new SqliteApiKeyAuditStore(_factory);
}
// --- Create ---
[Fact]
public async Task Create_ThenFindByKeyId_ReturnsRecord()
{
ApiKeyRecord record = SampleRecord("key-1");
await _admin.CreateAsync(record, CancellationToken.None);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(found);
Assert.Equal(record.SecretHash, found!.SecretHash);
Assert.True(record.Scopes.SetEquals(found.Scopes));
Assert.Equal(record.ConstraintsJson, found.ConstraintsJson);
Assert.Null(found.LastUsedUtc);
Assert.Null(found.RevokedUtc);
}
// --- Revoke ---
[Fact]
public async Task Revoke_ActiveKey_SetsRevokedAndFindActiveReturnsNull()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
bool result = await _admin.RevokeAsync("key-1", when, CancellationToken.None);
Assert.True(result);
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.Equal(when, found!.RevokedUtc);
}
[Fact]
public async Task Revoke_UnknownKey_ReturnsFalse()
{
bool result = await _admin.RevokeAsync("missing", DateTimeOffset.UtcNow, CancellationToken.None);
Assert.False(result);
}
[Fact]
public async Task Revoke_AlreadyRevoked_ReturnsFalse()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
bool result = await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
Assert.False(result);
}
// --- Rotate ---
[Fact]
public async Task Rotate_ChangesHashAndReactivates()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
await _read.MarkUsedAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
byte[] newHash = [9, 9, 9, 9];
bool result = await _admin.RotateAsync("key-1", newHash, CancellationToken.None);
Assert.True(result);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.Equal(newHash, found!.SecretHash);
Assert.Null(found.RevokedUtc);
Assert.Null(found.LastUsedUtc);
// Reactivated: now visible as active.
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
}
[Fact]
public async Task Rotate_UnknownKey_ReturnsFalse()
{
bool result = await _admin.RotateAsync("missing", [1], CancellationToken.None);
Assert.False(result);
}
// --- Delete ---
[Fact]
public async Task Delete_ActiveKey_ReturnsFalseAndKeyStillPresent()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
Assert.False(result);
Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
}
[Fact]
public async Task Delete_RevokedKey_ReturnsTrueAndKeyGone()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None);
bool result = await _admin.DeleteAsync("key-1", CancellationToken.None);
Assert.True(result);
Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None));
}
[Fact]
public async Task Delete_UnknownKey_ReturnsFalse()
{
bool result = await _admin.DeleteAsync("missing", CancellationToken.None);
Assert.False(result);
}
// --- keyId guard tests ---
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Revoke_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
// ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable;
// ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace.
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _admin.RevokeAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Rotate_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _admin.RotateAsync(keyId!, [1, 2, 3], CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Delete_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
}
// --- Audit ---
[Fact]
public async Task Audit_AppendThenListRecent_ReturnsEntry()
{
var entry = new ApiKeyAuditEntry(
KeyId: "key-1",
EventType: "created",
RemoteAddress: "10.0.0.1",
CreatedUtc: new DateTimeOffset(2026, 5, 31, 10, 0, 0, TimeSpan.Zero),
Details: "by admin");
await _audit.AppendAsync(entry, CancellationToken.None);
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
Assert.Single(recent);
Assert.Equal("key-1", recent[0].KeyId);
Assert.Equal("created", recent[0].EventType);
Assert.Equal("10.0.0.1", recent[0].RemoteAddress);
Assert.Equal("by admin", recent[0].Details);
}
[Fact]
public async Task Audit_ListRecent_ReturnsNewestFirst()
{
await _audit.AppendAsync(
new ApiKeyAuditEntry("k", "first", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
await _audit.AppendAsync(
new ApiKeyAuditEntry("k", "second", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
await _audit.AppendAsync(
new ApiKeyAuditEntry("k", "third", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
Assert.Equal(["third", "second", "first"], recent.Select(e => e.EventType));
}
[Fact]
public async Task Audit_ListRecent_RespectsLimit()
{
for (int i = 0; i < 5; i++)
{
await _audit.AppendAsync(
new ApiKeyAuditEntry("k", $"e{i}", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
}
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(2, CancellationToken.None);
Assert.Equal(2, recent.Count);
Assert.Equal(["e4", "e3"], recent.Select(e => e.EventType));
}
[Fact]
public async Task Audit_NullableFields_RoundTripAsNull()
{
await _audit.AppendAsync(
new ApiKeyAuditEntry(null, "anon", null, DateTimeOffset.UtcNow, null), CancellationToken.None);
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(10, CancellationToken.None);
Assert.Single(recent);
Assert.Null(recent[0].KeyId);
Assert.Null(recent[0].RemoteAddress);
Assert.Null(recent[0].Details);
}
private static ApiKeyRecord SampleRecord(string keyId) => new(
KeyId: keyId,
KeyPrefix: "mxgw_ab12",
SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
DisplayName: "Test Key " + keyId,
Scopes: new HashSet<string>(["read", "write"], StringComparer.Ordinal),
ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
LastUsedUtc: null,
RevokedUtc: null);
public Task DisposeAsync()
{
SqliteConnection.ClearAllPools();
TryDelete(_dbPath);
TryDelete(_dbPath + "-wal");
TryDelete(_dbPath + "-shm");
return Task.CompletedTask;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort cleanup of the per-test temp database.
}
}
}
@@ -0,0 +1,239 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
private AuthSqliteConnectionFactory _factory = null!;
private SqliteApiKeyStore _store = null!;
public async Task InitializeAsync()
{
_factory = new AuthSqliteConnectionFactory(_dbPath);
await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
_store = new SqliteApiKeyStore(_factory);
}
[Fact]
public async Task FindByKeyId_AfterInsert_ReturnsEqualRecord()
{
ApiKeyRecord record = SampleRecord("key-1");
await InsertAsync(record);
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(found);
AssertRecordEqual(record, found!);
}
[Fact]
public async Task FindByKeyId_ReturnsRevokedRecord()
{
ApiKeyRecord record = SampleRecord("key-revoked") with
{
RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
};
await InsertAsync(record);
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);
Assert.NotNull(found);
Assert.NotNull(found!.RevokedUtc);
}
[Fact]
public async Task FindActiveByKeyId_RevokedKey_ReturnsNull()
{
ApiKeyRecord record = SampleRecord("key-revoked") with
{
RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
};
await InsertAsync(record);
ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-revoked", CancellationToken.None);
Assert.Null(found);
}
[Fact]
public async Task FindActiveByKeyId_ActiveKey_ReturnsRecord()
{
await InsertAsync(SampleRecord("key-active"));
ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-active", CancellationToken.None);
Assert.NotNull(found);
}
[Fact]
public async Task FindByKeyId_UnknownKey_ReturnsNull()
{
ApiKeyRecord? found = await _store.FindByKeyIdAsync("missing", CancellationToken.None);
Assert.Null(found);
}
[Fact]
public async Task MarkUsed_ActiveKey_UpdatesLastUsed()
{
await InsertAsync(SampleRecord("key-active"));
var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
await _store.MarkUsedAsync("key-active", when, CancellationToken.None);
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-active", CancellationToken.None);
Assert.Equal(when, found!.LastUsedUtc);
}
[Fact]
public async Task MarkUsed_RevokedKey_DoesNotUpdateLastUsed()
{
ApiKeyRecord record = SampleRecord("key-revoked") with
{
RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
};
await InsertAsync(record);
var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
await _store.MarkUsedAsync("key-revoked", when, CancellationToken.None);
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None);
Assert.Null(found!.LastUsedUtc);
}
// --- keyId guard tests ---
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task FindByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
// ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable;
// ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace.
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _store.FindByKeyIdAsync(keyId!, CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task FindActiveByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _store.FindActiveByKeyIdAsync(keyId!, CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task MarkUsed_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None));
}
[Fact]
public void ScopeSerializer_RoundTripsAndSortsOrdinally()
{
var unsorted = new HashSet<string>(["zeta", "alpha", "mike"], StringComparer.Ordinal);
var differentOrder = new HashSet<string>(["mike", "zeta", "alpha"], StringComparer.Ordinal);
string a = ScopeSerializer.Serialize(unsorted);
string b = ScopeSerializer.Serialize(differentOrder);
// Equal sets must produce identical column text regardless of insertion order.
Assert.Equal(a, b);
Assert.Equal("""["alpha","mike","zeta"]""", a);
IReadOnlySet<string> roundTripped = ScopeSerializer.Deserialize(a);
Assert.True(roundTripped.SetEquals(unsorted));
}
[Fact]
public void ScopeSerializer_DeserializeNullOrEmpty_ReturnsEmptySet()
{
Assert.Empty(ScopeSerializer.Deserialize(null));
Assert.Empty(ScopeSerializer.Deserialize(""));
}
private static ApiKeyRecord SampleRecord(string keyId) => new(
KeyId: keyId,
KeyPrefix: "mxgw_ab12",
SecretHash: [1, 2, 3, 4, 5, 6, 7, 8],
DisplayName: "Test Key " + keyId,
Scopes: new HashSet<string>(["read", "write"], StringComparer.Ordinal),
ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""",
CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero),
LastUsedUtc: null,
RevokedUtc: null);
private static void AssertRecordEqual(ApiKeyRecord expected, ApiKeyRecord actual)
{
Assert.Equal(expected.KeyId, actual.KeyId);
Assert.Equal(expected.KeyPrefix, actual.KeyPrefix);
Assert.Equal(expected.SecretHash, actual.SecretHash);
Assert.Equal(expected.DisplayName, actual.DisplayName);
Assert.True(expected.Scopes.SetEquals(actual.Scopes));
Assert.Equal(expected.ConstraintsJson, actual.ConstraintsJson);
Assert.Equal(expected.CreatedUtc, actual.CreatedUtc);
Assert.Equal(expected.LastUsedUtc, actual.LastUsedUtc);
Assert.Equal(expected.RevokedUtc, actual.RevokedUtc);
}
private async Task InsertAsync(ApiKeyRecord record)
{
await using SqliteConnection connection =
await _factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
INSERT INTO api_keys (
key_id, key_prefix, secret_hash, display_name, scopes,
constraints, created_utc, last_used_utc, revoked_utc)
VALUES (
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
$constraints, $created_utc, $last_used_utc, $revoked_utc);
""";
command.Parameters.AddWithValue("$key_id", record.KeyId);
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash;
command.Parameters.AddWithValue("$display_name", record.DisplayName);
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes));
command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value);
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
await command.ExecuteNonQueryAsync(CancellationToken.None);
}
public Task DisposeAsync()
{
SqliteConnection.ClearAllPools();
TryDelete(_dbPath);
TryDelete(_dbPath + "-wal");
TryDelete(_dbPath + "-shm");
return Task.CompletedTask;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort cleanup of the per-test temp database.
}
}
}
@@ -0,0 +1,113 @@
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
public sealed class SqliteMigratorTests : IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");
private AuthSqliteConnectionFactory Factory => new(_dbPath);
[Fact]
public async Task MigrateAsync_CreatesAllThreeTables()
{
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeyAuditTable));
Assert.True(await TableExistsAsync(SqliteAuthSchema.SchemaVersionTable));
}
[Fact]
public async Task MigrateAsync_RunTwice_IsIdempotentAndRecordsCurrentVersion()
{
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadVersionAsync());
Assert.Equal(1, await CountSchemaVersionRowsAsync());
}
[Fact]
public async Task MigrateAsync_FutureSchemaVersion_Throws()
{
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await SetVersionAsync(99);
await Assert.ThrowsAsync<AuthStoreMigrationException>(
() => migrator.MigrateAsync(CancellationToken.None));
}
private async Task<bool> TableExistsAsync(string tableName)
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;";
command.Parameters.AddWithValue("$name", tableName);
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
return count == 1;
}
private async Task<int> ReadVersionAsync()
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
object? value = await command.ExecuteScalarAsync(CancellationToken.None);
return Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture);
}
private async Task<int> CountSchemaVersionRowsAsync()
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM schema_version;";
long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
return (int)count;
}
private async Task SetVersionAsync(int version)
{
await using SqliteConnection connection =
await Factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = "UPDATE schema_version SET version = $version WHERE id = 1;";
command.Parameters.AddWithValue("$version", version);
await command.ExecuteNonQueryAsync(CancellationToken.None);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
TryDelete(_dbPath);
TryDelete(_dbPath + "-wal");
TryDelete(_dbPath + "-shm");
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort cleanup of the per-test temp database.
}
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<!-- Back the relocated AddZbApiKeyAuth DI / migration / pepper-provider tests.
(Microsoft.Data.Sqlite flows in transitively via the ApiKeys project reference.) -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.ApiKeys\ZB.MOM.WW.Auth.ApiKeys.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,88 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.AspNetCore.Tests;
public class ServiceCollectionExtensionsTests
{
private const string LdapSection = "Auth:Ldap";
private const string LdapServer = "ldap.example.com";
private static IConfiguration BuildConfiguration() =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{LdapSection}:Server"] = LdapServer,
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
[$"{LdapSection}:ServiceAccountDn"] = "cn=svc,dc=example,dc=com",
[$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
})
.Build();
[Fact]
public void AddZbLdapAuth_ResolvesLdapAuthService()
{
IConfiguration config = BuildConfiguration();
var services = new ServiceCollection();
services.AddZbLdapAuth(config, LdapSection);
using ServiceProvider provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<ILdapAuthService>();
Assert.NotNull(service);
}
[Fact]
public void AddZbLdapAuth_ILdapAuthService_IsSingleton()
{
IConfiguration config = BuildConfiguration();
var services = new ServiceCollection();
services.AddZbLdapAuth(config, LdapSection);
using ServiceProvider provider = services.BuildServiceProvider();
var first = provider.GetRequiredService<ILdapAuthService>();
var second = provider.GetRequiredService<ILdapAuthService>();
Assert.Same(first, second);
}
[Fact]
public void AddZbLdapAuth_BindsOptionsFromSection()
{
IConfiguration config = BuildConfiguration();
var services = new ServiceCollection();
services.AddZbLdapAuth(config, LdapSection);
using ServiceProvider provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<LdapOptions>>();
Assert.Equal(LdapServer, options.Value.Server);
Assert.Equal("dc=example,dc=com", options.Value.SearchBase);
Assert.Equal(LdapTransport.Ldaps, options.Value.Transport);
}
[Fact]
public void AddZbLdapAuth_RegistersOptionsValidator()
{
IConfiguration config = BuildConfiguration();
var services = new ServiceCollection();
services.AddZbLdapAuth(config, LdapSection);
using ServiceProvider provider = services.BuildServiceProvider();
var validators = provider.GetServices<IValidateOptions<LdapOptions>>().ToList();
Assert.Contains(validators, v => v is LdapOptionsValidator);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Resolves CookieAuthenticationOptions, IServiceCollection, IConfiguration, and the
in-memory configuration provider used by the DI/cookie tests. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.AspNetCore\ZB.MOM.WW.Auth.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
<!-- Referenced so the DI tests can assert the concrete LdapOptionsValidator registration. -->
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,37 @@
using System.Security.Claims;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.Auth.AspNetCore.Tests;
public class ZbClaimTypesTests
{
[Fact]
public void Name_AliasesClaimTypesName()
{
Assert.Equal(ClaimTypes.Name, ZbClaimTypes.Name);
}
[Fact]
public void Role_AliasesClaimTypesRole()
{
Assert.Equal(ClaimTypes.Role, ZbClaimTypes.Role);
}
[Fact]
public void DisplayName_HasExpectedLiteralValue()
{
Assert.Equal("zb:displayname", ZbClaimTypes.DisplayName);
}
[Fact]
public void Username_HasExpectedLiteralValue()
{
Assert.Equal("zb:username", ZbClaimTypes.Username);
}
[Fact]
public void ScopeId_HasExpectedLiteralValue()
{
Assert.Equal("zb:scopeid", ZbClaimTypes.ScopeId);
}
}
@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.Auth.AspNetCore.Tests;
public class ZbCookieDefaultsTests
{
[Fact]
public void Apply_SetsHardenedCookieFlags()
{
var options = new CookieAuthenticationOptions();
ZbCookieDefaults.Apply(options);
Assert.True(options.Cookie.HttpOnly);
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
Assert.True(options.SlidingExpiration);
}
[Fact]
public void Apply_UsesSuppliedIdleTimeout()
{
var options = new CookieAuthenticationOptions();
var idle = TimeSpan.FromMinutes(12);
ZbCookieDefaults.Apply(options, idleTimeout: idle);
Assert.Equal(idle, options.ExpireTimeSpan);
}
[Fact]
public void Apply_DefaultsToDefaultIdleTimeout_WhenNotSupplied()
{
var options = new CookieAuthenticationOptions();
ZbCookieDefaults.Apply(options);
Assert.Equal(ZbCookieDefaults.DefaultIdleTimeout, options.ExpireTimeSpan);
}
[Fact]
public void Apply_RequireHttpsTrue_SetsSecurePolicyAlways()
{
var options = new CookieAuthenticationOptions();
ZbCookieDefaults.Apply(options, requireHttps: true);
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
}
[Fact]
public void Apply_RequireHttpsFalse_SetsSecurePolicySameAsRequest()
{
var options = new CookieAuthenticationOptions();
ZbCookieDefaults.Apply(options, requireHttps: false);
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
}
[Fact]
public void Apply_DefaultsRequireHttpsToAlways()
{
var options = new CookieAuthenticationOptions();
ZbCookieDefaults.Apply(options);
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
}
[Fact]
public void Apply_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => ZbCookieDefaults.Apply(null!));
}
}
@@ -0,0 +1,233 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary>
/// Test double for <see cref="ILdapConnection"/>. Script results and error
/// conditions with the builder methods; inspect recorded calls via properties.
/// Consumed by Task 5 (LdapAuthService) unit tests.
/// </summary>
internal sealed class FakeLdapConnection : ILdapConnection
{
// ---- scripted state -----
private readonly List<LdapSearchEntry> _scriptedEntries = new();
private readonly HashSet<string> _throwBindDns = new(StringComparer.OrdinalIgnoreCase);
private bool _throwOnConnect;
private bool _throwOnServiceBind;
private bool _throwOnUserBind;
// ---- observation -----
public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; }
public List<string> BoundDns { get; } = new();
/// <summary>
/// Count of <see cref="Bind"/> attempts (including ones that throw). The first attempt is
/// the service-account bind; the second is the user bind. Used to distinguish the two.
/// </summary>
public int BindAttempts { get; private set; }
// ---- builder methods -----
/// <summary>
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
/// </summary>
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
{
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
["memberOf"] = memberOf.ToList()
};
if (displayName is not null)
attrs["displayName"] = new[] { displayName };
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
return this;
}
/// <summary>
/// Configures the fake to throw <see cref="Novell.Directory.Ldap.LdapException"/> when
/// <see cref="Bind"/> is called for <paramref name="dn"/> (simulates bad credentials).
/// </summary>
public FakeLdapConnection ThrowOnBind(string dn)
{
_throwBindDns.Add(dn);
return this;
}
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> on the SECOND bind — the user
/// re-bind in the bind-then-search-then-bind flow — to simulate bad user credentials. The
/// first (service-account) bind still succeeds. Bind order, not DN, decides which one throws.
/// </summary>
public FakeLdapConnection ThrowOnUserBind()
{
_throwOnUserBind = true;
return this;
}
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> on the FIRST bind — the
/// service-account bind — to simulate a service-account misconfiguration. Distinct from
/// <see cref="ThrowOnUserBind"/>; this fails before the directory search ever runs.
/// </summary>
public FakeLdapConnection ThrowOnServiceBind()
{
_throwOnServiceBind = true;
return this;
}
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
/// simulate an unreachable directory (infrastructure failure).
/// </summary>
public FakeLdapConnection ThrowOnConnect()
{
_throwOnConnect = true;
return this;
}
/// <summary>
/// Scripts a search that returns ZERO entries (no <see cref="WithUserEntry"/> call also
/// yields zero, but this states the intent explicitly). Simulates user-not-found.
/// </summary>
public FakeLdapConnection WithNoMatch() => this;
/// <summary>
/// Scripts a search that returns TWO entries for the username, simulating an ambiguous /
/// non-unique match. Group/display-name content is irrelevant; only the count matters.
/// </summary>
public FakeLdapConnection WithDuplicateMatch()
{
WithUserEntry("cn=dup1,dc=x", new[] { "cn=g,dc=x" });
WithUserEntry("cn=dup2,dc=x", new[] { "cn=g,dc=x" });
return this;
}
// ---- ILdapConnection -----
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
{
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
if (_throwOnConnect)
throw new Novell.Directory.Ldap.LdapException(
"Directory unreachable", Novell.Directory.Ldap.LdapException.ConnectError, host);
}
public void Bind(string dn, string password)
{
BindAttempts++;
var isServiceBind = BindAttempts == 1;
if ((_throwOnServiceBind && isServiceBind)
|| (_throwOnUserBind && !isServiceBind)
|| _throwBindDns.Contains(dn))
{
throw new Novell.Directory.Ldap.LdapException(
"Invalid credentials", Novell.Directory.Ldap.LdapException.InvalidCredentials, dn);
}
BoundDns.Add(dn);
}
public IReadOnlyList<LdapSearchEntry> Search(
string searchBase,
string filter,
IReadOnlyList<string> attributes)
=> _scriptedEntries.AsReadOnly();
public void Dispose() { /* nothing to clean up */ }
}
/// <summary>Factory that always returns the same pre-configured fake instance.</summary>
internal sealed class FakeLdapConnectionFactory : ILdapConnectionFactory
{
/// <summary>Wraps a caller-supplied fake so a test can script it before handing it to the service.</summary>
public FakeLdapConnectionFactory(FakeLdapConnection fake) => Fake = fake;
/// <summary>Convenience overload that creates a bare, unscripted fake.</summary>
public FakeLdapConnectionFactory() : this(new FakeLdapConnection()) { }
public FakeLdapConnection Fake { get; }
public ILdapConnection Create() => Fake;
}
// ---------------------------------------------------------------------------
// Smoke test: verifies the fake compiles and scripted searches work correctly.
// ---------------------------------------------------------------------------
public class FakeLdapConnectionSmokeTests
{
[Fact]
public void ScriptedSearch_ReturnsEntry()
{
var fake = new FakeLdapConnection();
fake.WithUserEntry(
dn: "cn=alice,dc=example,dc=com",
memberOf: new[] { "cn=admins,dc=example,dc=com" },
displayName: "Alice Smith");
fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000);
var results = fake.Search("dc=example,dc=com", "(cn=alice)", new[] { "memberOf", "displayName" });
Assert.Single(results);
Assert.Equal("cn=alice,dc=example,dc=com", results[0].Dn);
Assert.Equal("Alice Smith", results[0].Attributes["displayName"][0]);
Assert.Equal("cn=admins,dc=example,dc=com", results[0].Attributes["memberOf"][0]);
}
[Fact]
public void Connect_RecordsArgs()
{
var fake = new FakeLdapConnection();
fake.Connect("ldap.example.com", 389, LdapTransport.StartTls, false, 10_000);
Assert.NotNull(fake.ConnectArgs);
Assert.Equal("ldap.example.com", fake.ConnectArgs!.Value.Host);
Assert.Equal(LdapTransport.StartTls, fake.ConnectArgs.Value.Transport);
}
[Fact]
public void ThrowOnUserBind_ThrowsOnSecondBindOnly()
{
var fake = new FakeLdapConnection().ThrowOnUserBind();
fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0);
// First bind = service account: succeeds.
fake.Bind("cn=svc,dc=example,dc=com", "secret");
// Second bind = user: throws (bad user credentials).
Assert.Throws<Novell.Directory.Ldap.LdapException>(
() => fake.Bind("cn=bob,dc=example,dc=com", "wrong"));
}
[Fact]
public void ThrowOnServiceBind_ThrowsOnFirstBind()
{
var fake = new FakeLdapConnection().ThrowOnServiceBind();
fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0);
Assert.Throws<Novell.Directory.Ldap.LdapException>(
() => fake.Bind("cn=svc,dc=example,dc=com", "secret"));
}
[Fact]
public void ThrowOnConnect_ThrowsLdapException()
{
var fake = new FakeLdapConnection().ThrowOnConnect();
Assert.Throws<Novell.Directory.Ldap.LdapException>(
() => fake.Connect("ldap.example.com", 389, LdapTransport.None, true, 0));
}
[Fact]
public void Bind_RecordsDn_WhenNotThrowing()
{
var fake = new FakeLdapConnection();
fake.Connect("ldap.example.com", 636, LdapTransport.Ldaps, false, 5000);
fake.Bind("cn=svc,dc=example,dc=com", "secret");
Assert.Contains("cn=svc,dc=example,dc=com", fake.BoundDns);
}
}
@@ -0,0 +1,102 @@
// GLAuth integration test — opt-in only.
//
// Prerequisites
// -------------
// 1. A running GLAuth instance (plaintext LDAP, no TLS).
// A ready-made Docker Compose stack lives in the sibling repo:
// ~/Desktop/ScadaBridge/infra/glauth
// Start it with: docker compose up -d
// Default listen address: localhost:3893
//
// 2. Set the following environment variables before running:
// ZB_LDAP_IT=1 (required — gates the test)
// ZB_LDAP_SERVER=localhost (optional, default localhost)
// ZB_LDAP_PORT=3893 (optional, default 3893)
// ZB_LDAP_BASE=dc=lmxopcua,dc=local (optional)
// ZB_LDAP_SVC_DN=cn=svc,dc=lmxopcua,dc=local (service-account DN)
// ZB_LDAP_SVC_PW=svcpass (service-account password)
// ZB_LDAP_USER=alice (test user login)
// ZB_LDAP_PW=alicepass (test user password)
// ZB_LDAP_USERATTR=cn (optional, default cn)
//
// Run command:
// ZB_LDAP_IT=1 ZB_LDAP_SVC_DN=... ZB_LDAP_SVC_PW=... \
// ZB_LDAP_USER=... ZB_LDAP_PW=... \
// dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
// --filter "FullyQualifiedName~GLAuthIntegrationTests"
//
// Without ZB_LDAP_IT=1 the test is SKIPPED — it does not affect the normal CI run.
using System.Net.Sockets;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests.Integration;
public sealed class GLAuthIntegrationTests
{
/// <summary>
/// Performs a real bind-then-search-then-bind against a live GLAuth instance.
/// Verifies that authentication succeeds and that at least one LDAP group is returned.
/// Skipped unless <c>ZB_LDAP_IT=1</c> is set; skipped again if the server is unreachable.
/// </summary>
[SkippableFact]
public async Task Authenticate_AgainstRealGLAuth_Succeeds()
{
// ------------------------------------------------------------------ opt-in gate
Skip.IfNot(
Environment.GetEnvironmentVariable("ZB_LDAP_IT") == "1",
"Set ZB_LDAP_IT=1 and a reachable GLAuth to run.");
// ------------------------------------------------------------------ read config
var server = Environment.GetEnvironmentVariable("ZB_LDAP_SERVER") ?? "localhost";
var port = int.TryParse(Environment.GetEnvironmentVariable("ZB_LDAP_PORT"), out var p) ? p : 3893;
var baseDn = Environment.GetEnvironmentVariable("ZB_LDAP_BASE") ?? "dc=lmxopcua,dc=local";
var svcDn = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_DN") ?? "";
var svcPw = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_PW") ?? "";
var user = Environment.GetEnvironmentVariable("ZB_LDAP_USER") ?? "";
var pw = Environment.GetEnvironmentVariable("ZB_LDAP_PW") ?? "";
var userAttr = Environment.GetEnvironmentVariable("ZB_LDAP_USERATTR") ?? "cn";
// ------------------------------------------------------------------ reachability probe
try
{
using var tcp = new TcpClient();
// 3-second connect timeout to keep the test suite snappy when the server is absent
var connectTask = tcp.ConnectAsync(server, port);
if (!connectTask.Wait(TimeSpan.FromSeconds(3)))
Skip.If(true, $"GLAuth not reachable at {server}:{port} (connect timed out).");
}
catch (Exception ex)
{
Skip.If(true, $"GLAuth not reachable at {server}:{port}: {ex.Message}");
}
// ------------------------------------------------------------------ build options
var options = new LdapOptions
{
Enabled = true,
Server = server,
Port = port,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = baseDn,
ServiceAccountDn = svcDn,
ServiceAccountPassword = svcPw,
UserNameAttribute = userAttr,
// GLAuth returns memberOf by default; keep the library default
GroupAttribute = "memberOf",
};
// ------------------------------------------------------------------ exercise the real service
// Uses the public single-argument constructor, which wires up NovellLdapConnectionFactory
// internally — no test seam involved.
var svc = new LdapAuthService(options);
var result = await svc.AuthenticateAsync(user, pw, default);
// ------------------------------------------------------------------ assertions
Assert.True(result.Succeeded,
$"Authentication failed: {result.Failure} (server={server}:{port}, user={user})");
Assert.NotEmpty(result.Groups);
}
}
@@ -0,0 +1,156 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
/// <summary>
/// Task 6 failure-mode tests. These pin the fail-closed contract: every error path returns a
/// structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>, the method never throws, and
/// a successful result always carries at least one group.
/// </summary>
public class LdapAuthServiceFailureTests
{
// Mirrors the happy-path test defaults (insecure plaintext dev transport, service account
// set, DisplayNameAttribute aligned with the fake's "displayName" key).
private static LdapOptions Opts() => new()
{
Enabled = true,
Server = "x",
Port = 3893,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=x",
ServiceAccountDn = "cn=svc,dc=x",
ServiceAccountPassword = "svcpw",
UserNameAttribute = "cn",
DisplayNameAttribute = "displayName",
GroupAttribute = "memberOf",
};
[Fact]
public async Task BadCredentials_WhenUserBindThrows()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
.ThrowOnUserBind();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "bad", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
}
[Fact]
public async Task UserNotFound_WhenZeroMatches()
{
var fake = new FakeLdapConnection().WithNoMatch();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("ghost", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.UserNotFound, r.Failure);
}
[Fact]
public async Task AmbiguousUser_WhenMultipleMatches()
{
var fake = new FakeLdapConnection().WithDuplicateMatch();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.AmbiguousUser, r.Failure);
}
[Fact]
public async Task AmbiguousUser_DoesNotAttemptUserBind()
{
var fake = new FakeLdapConnection().WithDuplicateMatch();
await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
// Only the service-account bind should have happened; never bind an ambiguous DN.
Assert.Equal(new[] { "cn=svc,dc=x" }, fake.BoundDns);
}
[Fact]
public async Task GroupLookupFailed_WhenUserHasNoGroups()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", memberOf: Array.Empty<string>());
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.GroupLookupFailed, r.Failure);
}
[Fact]
public async Task ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
.ThrowOnServiceBind();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
// Distinct from BadCredentials: a service-account problem is a system misconfiguration,
// not the end user's fault.
Assert.NotEqual(LdapAuthFailure.BadCredentials, r.Failure);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task BadCredentials_WhenUsernameNullOrWhitespace_NoConnectionAttempted(string? username)
{
// I4: an empty/whitespace/null username is rejected up front as BadCredentials,
// before any connection or bind is attempted (and a null can't NRE into the catch-all).
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync(username!, "pw", default);
Assert.False(r.Succeeded);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
Assert.Null(fake.ConnectArgs); // never connected
Assert.Empty(fake.BoundDns); // never bound
}
[Fact]
public async Task Throws_WhenCancellationRequested()
{
// I3: a pre-cancelled token is observed at entry, before any work.
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => svc.AuthenticateAsync("alice", "pw", new CancellationToken(canceled: true)));
Assert.Null(fake.ConnectArgs); // never connected
}
[Fact]
public async Task NeverThrows_OnConnectFailure()
{
var fake = new FakeLdapConnection()
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
.ThrowOnConnect();
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
.AuthenticateAsync("alice", "pw", default);
Assert.False(r.Succeeded);
// Directory unreachable is a system-side failure -> bucketed under ServiceAccountBindFailed.
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
}
}
@@ -0,0 +1,99 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
public class LdapAuthServiceTests
{
// Sensible test defaults: insecure plaintext transport (dev/test), a service
// account set, and DisplayNameAttribute aligned with the fake's "displayName"
// key so display-name extraction is genuinely exercised.
private static LdapOptions Opts() => new()
{
Enabled = true,
Server = "x",
Port = 3893,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=x",
ServiceAccountDn = "cn=svc,dc=x",
ServiceAccountPassword = "svcpw",
UserNameAttribute = "cn",
DisplayNameAttribute = "displayName",
GroupAttribute = "memberOf",
};
[Fact]
public async Task Succeeds_AndReturnsStrippedGroups_OnValidCredentials()
{
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x",
memberOf: new[] { "cn=Engineers,ou=g,dc=x", "cn=Viewers,ou=g,dc=x" },
displayName: "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync(" alice ", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("alice", r.Username); // trimmed
Assert.Equal("Alice", r.DisplayName); // from DisplayNameAttribute
Assert.Equal(new[] { "Engineers", "Viewers" }, r.Groups); // CN= stripped
}
[Fact]
public async Task BindsServiceAccountThenUser_OnValidCredentials()
{
// Non-empty memberOf: fail-closed requires at least one group for success, and this
// test asserts bind ORDER, so the user must successfully resolve and bind.
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" }, displayName: "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
await svc.AuthenticateAsync("alice", "pw", default);
// Service account first, user DN second (bind-then-search-then-bind).
Assert.Equal(new[] { "cn=svc,dc=x", "cn=alice,dc=x" }, fake.BoundDns);
}
[Fact]
public async Task FallsBackToUsername_WhenNoDisplayName()
{
// Non-empty memberOf so fail-closed lets success through; this test only asserts the
// display-name fallback (no displayName attribute -> username).
var fake = new FakeLdapConnection().WithUserEntry(
"cn=bob,dc=x", memberOf: new[] { "cn=Viewers,ou=g,dc=x" });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("bob", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("bob", r.DisplayName);
}
[Fact]
public async Task Fails_Disabled_WhenNotEnabled()
{
var svc = new LdapAuthService(
Opts() with { Enabled = false },
new FakeLdapConnectionFactory(new FakeLdapConnection()));
Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure);
}
[Fact]
public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn()
{
// C1: a group CN that legitimately contains a comma (escaped per RFC 4514)
// must be returned intact, not truncated at the escaped comma.
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x",
memberOf: new[] { @"cn=Eng\,ineers,ou=g,dc=x", @"cn=A\2cB,dc=x" },
displayName: "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("alice", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
}
}
@@ -0,0 +1,41 @@
using ZB.MOM.WW.Auth.Ldap.Internal;
public class LdapEscapingTests {
[Theory]
[InlineData("a*b", @"a\2ab")]
[InlineData("a(b)", @"a\28b\29")]
[InlineData(@"a\b", @"a\5cb")]
public void Filter_EscapesMetacharacters(string raw, string expected)
=> Assert.Equal(expected, LdapEscaping.Filter(raw));
[Fact]
public void Filter_EscapesNul()
=> Assert.Equal(@"a\00b", LdapEscaping.Filter("a\0b"));
[Fact]
public void Dn_EscapesSpecialChars()
=> Assert.Equal(@"\#cn\,test", LdapEscaping.Dn("#cn,test"));
// M2: each RFC 4514 special char is backslash-escaped, plus leading/trailing space.
[Theory]
[InlineData("a,b", @"a\,b")]
[InlineData("a+b", @"a\+b")]
[InlineData("a\"b", "a\\\"b")]
[InlineData(@"a\b", @"a\\b")]
[InlineData("a<b", @"a\<b")]
[InlineData("a>b", @"a\>b")]
[InlineData("a;b", @"a\;b")]
[InlineData(" ab", @"\ ab")]
[InlineData("ab ", @"ab\ ")]
public void Dn_EscapesEachSpecialChar(string raw, string expected)
=> Assert.Equal(expected, LdapEscaping.Dn(raw));
// C1: RFC 4514 escape-aware first-RDN-value extraction.
[Theory]
[InlineData("cn=Engineers,ou=g,dc=x", "Engineers")] // simple case still works
[InlineData(@"cn=Eng\,ineers,ou=g,dc=x", "Eng,ineers")] // single-char escaped comma
[InlineData(@"cn=A\2cB,dc=x", "A,B")] // hex-escaped comma \2c
[InlineData(@"cn=A\5cB,dc=x", @"A\B")] // hex-escaped backslash \5c
public void FirstRdnValue_IsEscapeAware(string dn, string expected)
=> Assert.Equal(expected, LdapEscaping.FirstRdnValue(dn));
}
@@ -0,0 +1,75 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
public class LdapOptionsValidatorTests
{
private static LdapOptions Opts() => new()
{
Enabled = true,
Server = "x",
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=x",
ServiceAccountDn = "cn=svc,dc=x",
};
[Fact]
public void Validator_Fails_PlainTransport_WhenNotAllowInsecure() =>
Assert.True(new LdapOptionsValidator()
.Validate(null, Opts() with { Transport = LdapTransport.None, AllowInsecure = false })
.Failed);
[Fact]
public void Validator_Fails_WhenServerEmpty() =>
Assert.True(new LdapOptionsValidator()
.Validate(null, Opts() with { Server = " " })
.Failed);
[Fact]
public void Validator_Fails_WhenSearchBaseEmpty() =>
Assert.True(new LdapOptionsValidator()
.Validate(null, Opts() with { SearchBase = "" })
.Failed);
[Fact]
public void Validator_FailureMessage_NamesOffendingField()
{
var result = new LdapOptionsValidator()
.Validate(null, Opts() with { Server = "" });
Assert.True(result.Failed);
Assert.Contains(nameof(LdapOptions.Server), result.FailureMessage);
}
[Fact]
public void Validator_Fails_WhenServiceAccountDnEmpty()
{
// I5: an empty ServiceAccountDn risks an anonymous bind, so it must be rejected
// and the failure message must name the offending key.
var result = new LdapOptionsValidator()
.Validate(null, Opts() with { ServiceAccountDn = " " });
Assert.True(result.Failed);
Assert.Contains(nameof(LdapOptions.ServiceAccountDn), result.FailureMessage);
}
[Fact]
public void Validator_Succeeds_OnValidSecureConfig() =>
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts() with
{
Transport = LdapTransport.Ldaps,
AllowInsecure = false,
Server = "s",
SearchBase = "dc=x",
})
.Failed);
[Fact]
public void Validator_Succeeds_OnInsecureWhenAllowed() =>
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts())
.Failed);
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Ldap\ZB.MOM.WW.Auth.Ldap.csproj" />
<ProjectReference Include="..\..\src\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>