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:
@@ -0,0 +1,8 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# .NET build output (ZB.MOM.WW.Auth/.gitignore also covers these; this is a root safety net)
|
||||
bin/
|
||||
obj/
|
||||
[Aa]rtifacts/
|
||||
*.user
|
||||
@@ -0,0 +1,190 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this repository is
|
||||
|
||||
`scadaproj` is primarily an umbrella/index workspace that aggregates a family of
|
||||
related SCADA / OT / Wonderware / OPC UA "sister projects" that live as **sibling
|
||||
directories under `~/Desktop/`**. It now also **hosts one piece of source itself** —
|
||||
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library (its own nested git repo), the
|
||||
realized output of the auth component normalization (see [Component normalization](#component-normalization)).
|
||||
The point of this file is to give a high-level scan of each sister project — its purpose,
|
||||
location, stack, and primary commands — so a fresh Claude Code session can orient across
|
||||
the whole family without opening each repo first.
|
||||
|
||||
Each sister project keeps its own authoritative `CLAUDE.md`. This index is a
|
||||
**summary of those files**; when you actually work in a project, open that project's
|
||||
own `CLAUDE.md` for the full picture. See [Refreshing this index](#refreshing-this-index).
|
||||
|
||||
> The project list below is **curated manually**. Add or remove entries as the family
|
||||
> changes — do not assume every directory under `~/Desktop/` belongs here.
|
||||
|
||||
## Sister projects (core SCADA/OT family)
|
||||
|
||||
### Runtime / implementation (active code)
|
||||
|
||||
| Project | Location | Stack | Repo | Summary |
|
||||
|---|---|---|---|---|
|
||||
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes AVEVA System Platform (Wonderware) Galaxy tags as an OPC UA address space. Galaxy access flows through an in-process `GalaxyDriver` → gRPC → the **mxaccessgw** gateway. |
|
||||
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** |
|
||||
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | _git_ | Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. |
|
||||
|
||||
## Cross-project relationships
|
||||
|
||||
The three indexed projects are **separate repos and separate processes**, coupled at
|
||||
**runtime over wire protocols (gRPC + OPC UA)** — not by project/compile references. They
|
||||
share the `ZB.MOM.WW.*` product namespace (`ZB.MOM.WW.OtOpcUa.*`, `ZB.MOM.WW.ScadaBridge.*`;
|
||||
the gateway uses `MxGateway.*`). The common subject is **AVEVA System Platform (Wonderware)
|
||||
"Galaxy"** data, and `mxaccessgw` is the linchpin that the other two connect through.
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
AVEVA System Platform — Wonderware "Galaxy"
|
||||
(OT source of truth: runtime tags + Galaxy Repository SQL DB)
|
||||
▲
|
||||
│ MXAccess COM (32-bit, STA message pump)
|
||||
│
|
||||
┌────────────────┴─────────────────┐
|
||||
│ MxAccessGateway (mxaccessgw) │ gateway x64 .NET10 + worker x86 net48
|
||||
│ gRPC service; OWNS the 32-bit │ protos: mxaccess_gateway / mxaccess_worker
|
||||
│ COM bitness + STA pump │ / galaxy_repository
|
||||
└──────┬─────────────────────┬──────┘
|
||||
gRPC (MxCommand/MxEvent + │ gRPC (ScadaBridge "MxGateway" adapter:
|
||||
GalaxyRepository browse) │ native MxAccess data + A&C alarms)
|
||||
│ │
|
||||
┌─────┴──────┐ │
|
||||
│ OtOpcUa │ GalaxyDriver maps │
|
||||
│ OPC UA srv │ Galaxy hierarchy → │
|
||||
│ (.NET 10) │ OPC UA addr space │
|
||||
└─────┬──────┘ │
|
||||
│ OPC UA (opc.tcp; data + A&C alarms)
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ ScadaBridge — Data Connection Layer (DCL) │
|
||||
│ OPC UA adapter │ MxGateway adapter │ custom │
|
||||
└─────────────────────────┬────────────────────┘
|
||||
▼
|
||||
Instance Actors → site clusters → central cluster / UI
|
||||
```
|
||||
|
||||
### Edge-by-edge
|
||||
|
||||
- **MxAccessGateway is the foundation.** It is the *only* component that loads 32-bit
|
||||
MXAccess COM (its x86 net48 worker owns the COM apartment + STA pump). It exposes that to
|
||||
modern x64/.NET-10 callers over gRPC, and also serves Galaxy Repository SQL browse RPCs.
|
||||
This is *why* the other two exist as .NET 10 / x64 and never touch COM directly.
|
||||
- **OtOpcUa → MxAccessGateway** (gRPC client). OtOpcUa's in-process `GalaxyDriver`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/`) uses two gateway channels: the
|
||||
`GalaxyRepositoryClient` for the static hierarchy, and an MXAccess session
|
||||
(`MxCommand`/`MxEvent` protos) for live read/write/subscribe. A `DeployWatcher` polls the
|
||||
gateway's deploy-event signal to rebuild the OPC UA address space on Galaxy redeploy.
|
||||
OtOpcUa's job is purely a **protocol bridge**: it republishes Galaxy as an OPC UA address
|
||||
space for *any* OPC UA client.
|
||||
- **ScadaBridge → OPC UA** (OPC UA client). ScadaBridge's DCL has an OPC UA adapter that
|
||||
collects data and mirrors native OPC UA Alarms & Conditions. OtOpcUa is exactly such a
|
||||
server, so ScadaBridge can ingest Wonderware data **indirectly via OtOpcUa**.
|
||||
- **ScadaBridge → MxAccessGateway** (gRPC client). The DCL *also* has a dedicated
|
||||
**MxGateway adapter** that talks to `mxaccessgw` directly for native MxAccess data and
|
||||
alarms — so ScadaBridge can reach Wonderware data **directly, bypassing OtOpcUa**.
|
||||
Both adapters implement the same `IAlarmSubscribableConnection` seam, and a read-only
|
||||
`NativeAlarmActor` unifies OPC-UA-A&C and MxAccess alarms onto one condition model.
|
||||
|
||||
### Net effect
|
||||
|
||||
- `mxaccessgw` is a shared dependency of **both** OtOpcUa and ScadaBridge.
|
||||
- ScadaBridge has **two paths** to the same Wonderware data: (1) OPC UA → OtOpcUa →
|
||||
gateway, or (2) MxGateway adapter → gateway directly. Path 1 gives standards-based OPC UA
|
||||
decoupling; path 2 gives a more direct/native feed.
|
||||
- Coupling is loose: each repo references the others only as **sibling context** (the
|
||||
`## Sister Projects` note in ScadaBridge's own `CLAUDE.md` lists `MxAccessGateway` and
|
||||
`OtOpcUa` with their Gitea URLs but states they are *not part of its solution*).
|
||||
- **The break surface is the wire contracts, not code.** Because coupling is by network
|
||||
protocol, the things that break across repo boundaries are: the gateway's `.proto` files
|
||||
(`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`), and the
|
||||
OPC UA address-space shape OtOpcUa publishes (browse paths, node IDs, A&C alarm model).
|
||||
Changes to any of these must be coordinated across the affected repos — a green build in
|
||||
one repo does not prove the others still interoperate.
|
||||
|
||||
## Component normalization
|
||||
|
||||
Because the sister repos re-implement the same cross-cutting concerns separately and drift
|
||||
apart, [`components/`](components/) normalizes them: per component, the **one target** spec,
|
||||
each project's **code-verified current state**, and the **gaps** between. See
|
||||
[`components/README.md`](components/README.md) for the convention and workflow.
|
||||
|
||||
| Component | Status | Goal | Design | Implementation |
|
||||
|---|---|---|---|---|
|
||||
| Auth (login / identity / authz) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
|
||||
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
||||
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
|
||||
[`current-state`](components/auth/current-state/) docs, and an adoption [`GAPS`](components/auth/GAPS.md)
|
||||
backlog. Common ground = LDAP/GLAuth identity + peppered-HMAC API keys; left per-project =
|
||||
the authz vocabularies (OPC-UA permissions / gRPC scopes / roles + site-scoping).
|
||||
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/)
|
||||
(its own nested git repo; .NET 10; 4 packages — `Abstractions`, `Ldap`, `ApiKeys`, `AspNetCore`;
|
||||
172 tests; `dotnet pack` → 4 nupkgs @ 0.1.0). The implementation plan is at
|
||||
[`docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md).
|
||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/auth/GAPS.md`](components/auth/GAPS.md) (#8).
|
||||
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
|
||||
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
|
||||
|
||||
## Per-project primary commands
|
||||
|
||||
Run these from inside each project directory (not from `scadaproj`).
|
||||
|
||||
```bash
|
||||
# OtOpcUa
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||
dotnet test ZB.MOM.WW.OtOpcUa.slnx
|
||||
dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # single test
|
||||
# Docker fixtures run on shared host 10.100.0.35; control via `lmxopcua-fix` (in ~/bin)
|
||||
|
||||
# MxAccessGateway (PowerShell on Windows)
|
||||
dotnet build src/MxGateway.sln
|
||||
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86 # worker MUST be x86
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj # no MXAccess needed (fake worker)
|
||||
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
|
||||
|
||||
# ScadaBridge (~/Desktop/ScadaBridge)
|
||||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||||
bash docker/deploy.sh # rebuild + redeploy the 8-node cluster
|
||||
cd infra && docker compose up -d # local test services (LDAP, SQL, OPC UA, SMTP, REST, Traefik)
|
||||
```
|
||||
|
||||
## Refreshing this index
|
||||
|
||||
This file is meant to be re-scanned when `scadaproj` is opened in Claude Code:
|
||||
|
||||
1. List sibling SCADA/OT directories: `find ~/Desktop -maxdepth 2 -iname "claude.md"`.
|
||||
2. For each project the user wants indexed, read the **top of its `CLAUDE.md`**
|
||||
(project overview + build/run sections) and update its row above.
|
||||
3. Keep the project set **curated** — only the SCADA/OT/Wonderware/OPC-UA family belongs here.
|
||||
4. Flag new duplicates/overlaps and namespace mismatches rather than silently merging them.
|
||||
|
||||
## Other workspace projects with a CLAUDE.md (not indexed — promote on request)
|
||||
|
||||
Listed so they can be pulled into the index above if you decide they belong.
|
||||
|
||||
**SCADA/OT, de-indexed (still have a `CLAUDE.md` under `~/Desktop/`):**
|
||||
|
||||
- **OpcUaTestServer** — dual-instance OPC UA test server (.NET 10) for testing OPC UA clients / simulating automation.
|
||||
- **scada** (ScadaLink) — design docs + scaffolding for the distributed SCADA platform (`ZB.MOM.ScadaLink.*`).
|
||||
- **scadalink-design-opcua-browser** — ScadaBridge variant focused on OPC UA browser / Data Connection Layer work.
|
||||
- **DARS** — DARS → Wonderware SCADA migration (design phase, Q2 2026 go-live).
|
||||
- **DARS_BU** — backup snapshot of DARS.
|
||||
- **plan** — 3-year SCADA IT/OT modernization roadmap (markdown).
|
||||
|
||||
**Outside the SCADA family:**
|
||||
|
||||
- **delmia** — DELMIA / Apriso (Intercim Velocity) MES customization export (ASP.NET `.asmx`); MES-adjacent.
|
||||
- **lightctrl** — Raspberry Pi Python I/O control (edge hardware).
|
||||
- **codestats** — Rust CLI for code statistics on .NET solutions/dirs (dev tooling for the .NET repos).
|
||||
- **servecli** — Rust portable SSH/SFTP server for Windows remote task management (greenfield).
|
||||
- **JdeScopingTool** — JD Edwards "LotFinder" .NET 4.8 → .NET 10 migration (ERP, not SCADA).
|
||||
- **chat** — local-first roleplay chat engine.
|
||||
- **candy2** — candy.ai chat scraping / browser automation.
|
||||
- **menardslist** — Menards.com cart → printable picklist PDF.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Executable
+9
@@ -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
|
||||
Executable
+24
@@ -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><prefix>_<keyId>_<secret></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><tokenPrefix>_<keyId>_<secret></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);
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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: , + " \ < > ; (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));
|
||||
}
|
||||
}
|
||||
+200
@@ -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>
|
||||
+88
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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>
|
||||
@@ -0,0 +1,73 @@
|
||||
# Component normalization
|
||||
|
||||
This tree normalizes how cross-cutting components work across the sister projects
|
||||
indexed in [`../CLAUDE.md`](../CLAUDE.md) (currently **OtOpcUa**, **MxAccessGateway**,
|
||||
**ScadaBridge**). The sister repos are deliberately decoupled — separate processes
|
||||
coupled only over wire protocols — so the same concern (auth, logging, config, health,
|
||||
…) tends to get re-implemented three times and drift apart. This folder is where we
|
||||
write down the **one target** for each such component, record **where each project
|
||||
stands today** (verified against real code), and track the **gaps** that close the
|
||||
distance between them.
|
||||
|
||||
The goal is convergence toward shared, versioned contracts/libraries — see each
|
||||
component's `shared-contract/`. Nothing here changes project code directly; these are
|
||||
specs and analyses that *drive* changes made in the individual repos.
|
||||
|
||||
## Component registry
|
||||
|
||||
| Component | Status | Applies to | Goal | Folder |
|
||||
|---|---|---|---|---|
|
||||
| Auth (login / identity / authz) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Auth`) | [`auth/`](auth/) |
|
||||
|
||||
> Add a row when you start normalizing a new component. Status: `Draft` → `Reviewed` → `Adopting` → `Converged`.
|
||||
|
||||
## Folder convention
|
||||
|
||||
Every normalized component is one subfolder of `components/` with this layout:
|
||||
|
||||
```
|
||||
<component>/
|
||||
README.md # overview + per-project status table (links into the docs below)
|
||||
spec/
|
||||
SPEC.md # the ONE normalized target for this component
|
||||
shared-contract/ # only when the goal is shared code
|
||||
<Package>.md # proposed shared-library API: packages, interfaces, options, records
|
||||
current-state/
|
||||
<project>/ # one subfolder per project the component applies to
|
||||
CURRENT-STATE.md # how it works in THAT project today (code-verified) + adoption plan
|
||||
GAPS.md # divergences of each project vs SPEC.md + the extraction/adoption backlog
|
||||
```
|
||||
|
||||
- **`spec/SPEC.md`** is authoritative: the single design every project should converge on.
|
||||
- **`shared-contract/`** exists only when the component's goal is *shared code* (vs docs-only
|
||||
convergence). It is the proposed public API of the library to extract — a contract on paper,
|
||||
not a created package.
|
||||
- **`current-state/<project>/`** is descriptive, not aspirational. It must match the project's
|
||||
real code, with `file:line` references. Each ends in an **Adoption plan**: what that project
|
||||
deletes/replaces to reach the spec, and what stays bespoke.
|
||||
- **`GAPS.md`** is the working backlog: it turns the delta between every `current-state` and the
|
||||
`spec` into concrete, prioritized items.
|
||||
|
||||
## Workflow to normalize a component
|
||||
|
||||
1. **Map current state from code.** For each applicable project, read the actual implementation
|
||||
(not just its `CLAUDE.md`) and write `current-state/<project>/CURRENT-STATE.md` with `file:line`
|
||||
refs. Fan out one reader per project — they're independent.
|
||||
2. **Write the target.** Synthesize the common ground and the divergences into `spec/SPEC.md` —
|
||||
the one design. Call out explicitly what is normalized vs. what stays per-project (domain
|
||||
differences that should *not* be forced together).
|
||||
3. **Propose the contract (if shared-code goal).** Turn the spec's normalized seams into a
|
||||
concrete library API in `shared-contract/`. Keep the surface minimal — extract only what is
|
||||
genuinely common; leave domain-specific logic in the projects.
|
||||
4. **Log gaps + adoption.** Fill `GAPS.md` with per-project divergences and an adoption/extraction
|
||||
backlog (priority / effort / risk). Add the per-project "what to delete/replace" to each
|
||||
`current-state` doc.
|
||||
5. **Register.** Add/maintain the component's row in the registry table above.
|
||||
|
||||
## Maintenance rules
|
||||
|
||||
- `current-state/<project>/CURRENT-STATE.md` must stay **code-verified**. When a project's auth (or
|
||||
whichever component) changes materially, update its current-state doc and re-check `GAPS.md`.
|
||||
- The `spec/` is changed deliberately, with the same "update the doc *and* the consequences" discipline
|
||||
the sister repos use — moving the target re-opens gaps.
|
||||
- Keep cross-references accurate: `README.md` status tables link to the docs; don't let them rot.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Auth — gaps & adoption backlog
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
## Divergence vs spec
|
||||
|
||||
### §1 LDAP config schema
|
||||
|
||||
| Spec key | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| Section nesting | 🟡 `Authentication:Ldap` (nested) | ✅ `MxGateway:Ldap` (nested) | ⛔ flat `ScadaBridge:Security:Ldap*` |
|
||||
| `Transport` enum | ⛔ `UseTls` bool | ⛔ `UseTls` bool | ✅ `LdapTransport` enum |
|
||||
| `AllowInsecure` | 🟡 `AllowInsecureLdap` | 🟡 `AllowInsecureLdap` | 🟡 `AllowInsecureLdap` (rename) |
|
||||
| `UserNameAttribute` | ✅ `UserNameAttribute` | ✅ `UserNameAttribute` | ⛔ `LdapUserIdAttribute` |
|
||||
| `GroupAttribute` | ✅ `memberOf` | ✅ `memberOf` | 🟡 `LdapGroupAttribute` (rename) |
|
||||
| dev `SearchBase` | `dc=lmxopcua,dc=local` | `dc=lmxopcua,dc=local` | `dc=scadabridge,dc=local` |
|
||||
|
||||
→ **Gap A1:** adopt the `Transport` enum in OtOpcUa + gateway (replace `UseTls`).
|
||||
→ **Gap A2:** ScadaBridge: nest keys + rename `LdapUserIdAttribute`→`UserNameAttribute`, `LdapGroupAttribute`→`GroupAttribute`.
|
||||
→ **Gap A3:** unify the **dev base DN** (`dc=lmxopcua` vs `dc=scadabridge`) — pick one shared GLAuth base.
|
||||
|
||||
### §2 bind-then-search
|
||||
|
||||
All three do bind-then-search; ScadaBridge has the most complete hygiene (RFC-4514 + filter
|
||||
escaping, per-op timeout, fail-closed on group lookup, username trim, service-account-bind
|
||||
distinction). 🟡 OtOpcUa/gateway: confirm each has filter escaping + fail-closed-on-group-lookup
|
||||
parity. → **Gap B1:** make ScadaBridge's hygiene the shared baseline; backfill any missing checks.
|
||||
|
||||
### §3 group→role mapping
|
||||
|
||||
⛔ **Mechanism split:** OtOpcUa + gateway map in **config** (`GroupToRole`); ScadaBridge maps in
|
||||
the **database** (`LdapGroupMapping`). → **Gap C1:** `IGroupRoleMapper<CanonicalRole>` must support both
|
||||
backings; ship a config-backed and a DB/delegate-backed mapper.
|
||||
|
||||
⛔ **Role vocabulary now standardized** to the canonical six ([`spec/CANONICAL-ROLES.md`](spec/CANONICAL-ROLES.md));
|
||||
native enforcement stays per-project. → **Gap C2:** implement the `canonical → native` expansion in each
|
||||
project. ⚠ Removing Auditor collapses ScadaBridge `AuditReadOnly`→Viewer and `Audit`→Administrator,
|
||||
losing its auditor/admin separation-of-duties (accepted). OtOpcUa lacks a first-class `Deployer`
|
||||
(publish ⊂ `FleetAdmin`); ScadaBridge has no `Operator`/`Engineer`; mxaccessgw no `Designer`/`Deployer` —
|
||||
each project assigns only the applicable subset.
|
||||
|
||||
### §4 API-key contract
|
||||
|
||||
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| Has API keys | n/a (OPC UA transport security) | ✅ `mxgw_…`, SQLite, scopes + constraints | 🟡 `X-API-Key`, per-method approval (Inbound API only) |
|
||||
| Peppered HMAC-SHA256 | — | ✅ | ✅ |
|
||||
| Constant-time compare | — | ✅ | ✅ |
|
||||
| Token format `<prefix>_<id>_<secret>` | — | ✅ | ⛔ raw `X-API-Key` (no keyId/prefix structure) |
|
||||
| Audit log | — | ✅ append-only | 🟡 (verify) |
|
||||
|
||||
→ **Gap D1:** extract mxaccessgw's pipeline as `ZB.MOM.WW.Auth.ApiKeys`.
|
||||
→ **Gap D2:** ScadaBridge Inbound API adopts it; reconcile token format and model "per-method approval" as the opaque constraint policy.
|
||||
|
||||
### §5 cookie / claim conventions
|
||||
|
||||
⛔ Cookie names differ (`MxGatewayDashboard` vs `ZB.MOM.WW.ScadaBridge.Auth` vs OtOpcUa control-plane cookie); claim-type conventions differ. → **Gap E1:** define canonical claim types + cookie defaults in `ZB.MOM.WW.Auth.AspNetCore`; each app keeps its own cookie *name* but shares attributes/claims.
|
||||
|
||||
### §6 dev / secrets
|
||||
|
||||
✅ All never-log-secrets and pepper-external. 🟡 escape-hatch flag names vary. Covered by A3 (base DN) + E.
|
||||
|
||||
## Adoption backlog (ordered)
|
||||
|
||||
| # | Item | Projects | Priority | Effort | Risk | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Extract `ZB.MOM.WW.Auth.Ldap` from ScadaBridge's hardened impl | all 3 | High | M | Med | security-sensitive; needs strong tests before cutover |
|
||||
| 2 | Extract `ZB.MOM.WW.Auth.ApiKeys` from mxaccessgw Model A | gw, SB | High | M | Med | gateway adopts first (it's the source), then SB |
|
||||
| 3 | `IGroupRoleMapper<TRole>` seam + config & DB mappers (Gap C1) | all 3 | High | S | Low | unblocks per-project role retention |
|
||||
| 4 | Config migration to §1 schema (Gaps A1–A2) | all 3 | Med | S | Low | mechanical; do with #1 |
|
||||
| 5 | `ZB.MOM.WW.Auth.AspNetCore` claims/cookie conventions (Gap E1) | all 3 (UIs) | Med | S | Low | incl. OtOpcUa Blazor Admin UI control-plane |
|
||||
| 6 | Unify dev GLAuth base DN (Gap A3) | all 3 | Low | S | Low | dev-only; touches fixtures/infra |
|
||||
| 7 | Decide shared JWT/refresh helper vs per-project | SB (+?) | Low | S | Low | only if a 2nd project wants the same |
|
||||
| 8 | Adopt canonical roles: `canonical → native` mapping per project (Gap C2) | all 3 | Med | M | Med | governance (assign canonical role per LDAP group org-wide) + each project's expansion; SB audit roles collapse |
|
||||
|
||||
**Sequencing:** #3 first (cheap, unblocks), then #1 and #2 in parallel (independent libraries),
|
||||
then #4–#5 alongside cutover, then #6–#7 as cleanup. Each extraction lands behind tests in the
|
||||
source project before any consumer migrates. This stays consistent with the repos' loose coupling:
|
||||
adoption is opt-in per project, one consumer version-bump at a time.
|
||||
|
||||
## Decisions still open
|
||||
- Shared dev base DN value (A3).
|
||||
- Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3).
|
||||
- Shared JWT/refresh helper or not (#7).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Auth (login / identity / authorization)
|
||||
|
||||
First normalized component. **Goal: path to shared code** — converge the three sister
|
||||
projects onto a common identity + API-key contract, proposed as the `ZB.MOM.WW.Auth`
|
||||
library set, while each project keeps its own authorization vocabulary.
|
||||
|
||||
- The one target: [`spec/SPEC.md`](spec/SPEC.md)
|
||||
- The proposed shared library: [`shared-contract/ZB.MOM.WW.Auth.md`](shared-contract/ZB.MOM.WW.Auth.md)
|
||||
- Divergences + backlog: [`GAPS.md`](GAPS.md)
|
||||
- Current state, per project: [`current-state/`](current-state/)
|
||||
|
||||
## Why auth is a strong first candidate
|
||||
|
||||
All three projects authenticate humans via **LDAP** (GLAuth in dev), do **bind-then-search**,
|
||||
read groups from **`memberOf`**, use a **service account**, support **TLS/StartTLS** with an
|
||||
`AllowInsecureLdap` dev escape hatch, and **never log secrets**. Two of three implement an
|
||||
almost identical **peppered HMAC-SHA256 API-key** scheme with constant-time comparison. That
|
||||
common core is re-implemented per repo and has already drifted (config key names, dev base DN,
|
||||
cookie names). Authorization, by contrast, is genuinely domain-specific and is **not** unified.
|
||||
|
||||
## Status by project
|
||||
|
||||
| Project | AuthN today | Machine auth | AuthZ model (stays per-project) | Sessions | Adoption status |
|
||||
|---|---|---|---|---|---|
|
||||
| **OtOpcUa** | LDAP (GLAuth) via OPC UA UserName token; X.509 + anonymous also | — (OPC UA transport security) | `NodePermissions` bitmask (data-plane ACL trie) + `AdminRole` (control-plane) | Per-session `UserAuthorizationState`, 5-min freshness / 15-min staleness, generation-bound | Not started |
|
||||
| **MxAccessGateway** | LDAP (GLAuth) for **dashboard** | **API keys** (`mxgw_…`, SQLite, peppered HMAC, scopes + constraints) | gRPC **scopes** (`session:*`/`invoke:*`/`events:*`/`metadata:*`/`admin`) + dashboard `Admin`/`Viewer` | Dashboard cookie (8h sliding) + 30-min Data-Protection hub bearer | Not started |
|
||||
| **ScadaBridge** | LDAP for UI/CLI/Management API (Basic→LDAP) | **API keys** (`X-API-Key`, peppered HMAC, per-method approval) — Inbound API only | Roles `Admin`/`Design`/`Deployment`/`Audit`/`AuditReadOnly` + **site-scoping** | Cookie (`…ScadaBridge.Auth`, 30-min idle) + 15-min refresh JWT for programmatic | Not started |
|
||||
|
||||
See each project's [`current-state/<project>/CURRENT-STATE.md`](current-state/) for the
|
||||
code-verified detail and its adoption plan.
|
||||
|
||||
## Normalized vs. left per-project
|
||||
|
||||
**Normalized (the shared target):** LDAP/identity config schema + canonical key names;
|
||||
bind-then-search behavior incl. DN/filter escaping and timeouts; a generic group→role
|
||||
mapping seam; **the standardized canonical role set every project maps onto
|
||||
([`spec/CANONICAL-ROLES.md`](spec/CANONICAL-ROLES.md))**; the API-key contract (token format,
|
||||
peppered HMAC-SHA256, constant-time compare, audit); cookie/claim conventions; dev-bypass
|
||||
flag conventions; secret handling.
|
||||
|
||||
**Left per-project (native enforcement, mapped onto the canonical roles):** the authorization
|
||||
*enforcement* vocabularies (`NodePermissions` / gRPC scopes / app roles + site-scoping), OPC UA
|
||||
transport security, OtOpcUa's generation/staleness session model, ScadaBridge's site-scope rules.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Auth — current state: MxAccessGateway (`mxaccessgw`)
|
||||
|
||||
Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10 gateway (x64) + x86/net48 worker.
|
||||
Auth lives entirely in the **gateway** (.NET 10); the worker never authenticates.
|
||||
All paths relative to repo root; auth code under `src/ZB.MOM.WW.MxGateway.Server/`. Verified 2026-06-01.
|
||||
|
||||
The gateway has **two independent auth models**: gRPC API keys (programmatic clients) and
|
||||
LDAP dashboard auth (web UI). They share nothing — different credentials, stores, and authz.
|
||||
|
||||
## Model A — gRPC API-key auth
|
||||
|
||||
Base: `Security/Authentication/` and `Security/Authorization/`.
|
||||
|
||||
- **Token format:** `Authorization: Bearer mxgw_<key-id>_<secret>`. Parsed by `ApiKeyParser.cs` (rejects malformed before any DB hit) → `ParsedApiKey(KeyId, Secret)`.
|
||||
- **Hashing:** `ApiKeySecretHasher.cs` — HMAC-SHA256 with an **external pepper** (config key `MxGateway:ApiKeyPepper`, never stored beside the hash). Secrets generated by `ApiKeySecretGenerator.cs` (32 random bytes, URL-safe base64).
|
||||
- **Verification:** `ApiKeyVerifier.cs` — parse → `IApiKeyStore.FindByKeyIdAsync` → reject if revoked → hash with pepper → **constant-time compare** (`CryptographicOperations.FixedTimeEquals`) → mark `LastUsedUtc`. Failures discriminated (`KeyNotFound/KeyRevoked/PepperUnavailable/SecretMismatch/MissingOrMalformedCredentials`) for audit without leaking to clients.
|
||||
- **Storage:** SQLite (`AuthSqliteConnectionFactory.cs`, WAL), default `C:\ProgramData\MxGateway\gateway-auth.db`. Schema v2 (`SqliteAuthSchema.cs`): tables `api_keys` (hash, scopes JSON, constraints JSON, created/last_used/revoked), `api_key_audit` (append-only), `schema_version`. Read/write/audit split across `SqliteApiKeyStore` / `SqliteApiKeyAdminStore` / `SqliteApiKeyAuditStore`.
|
||||
- **Scopes** (`GatewayScopes.cs`): `session:open/close`, `invoke:read/write/secure`, `events:read`, `metadata:read`, `admin`. Stored per key (ordinal-sorted JSON via `ApiKeyScopeSerializer.cs`).
|
||||
- **Enforcement:** `Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs` + `GatewayGrpcScopeResolver.cs` — per RPC: authenticate (`Unauthenticated` on failure) → resolve required scope → check (`PermissionDenied` if missing) → push `ApiKeyIdentity` into async-local `IGatewayRequestIdentityAccessor`.
|
||||
- **Constraints** (`ApiKeyConstraints.cs` + `ConstraintEnforcer.cs`): optional fine-grained limits — `ReadSubtrees/WriteSubtrees/ReadTagGlobs/WriteTagGlobs/BrowseSubtrees` (anchored case-insensitive globs), `MaxWriteClassification`, `ReadAlarmOnly`, `ReadHistorizedOnly`.
|
||||
- **Admin CLI:** `apikey` subcommand (`ApiKeyAdminCliRunner.cs`, `ApiKeyAdminCommandLineParser.cs`): `init-db / create-key / list-keys / revoke-key / rotate-key / delete-key` (+ constraint flags). `delete` only works on already-revoked keys.
|
||||
- **Config:** `Configuration/AuthenticationOptions.cs` → `MxGateway:Authentication:{Mode(ApiKey|Disabled), SqlitePath, PepperSecretName, RunMigrationsOnStartup}`.
|
||||
|
||||
## Model B — Dashboard LDAP auth (Blazor)
|
||||
|
||||
Base: `Dashboard/`.
|
||||
|
||||
- **Login:** `DashboardAuthenticator.cs` — connect (`MxGateway:Ldap`), bind service account, search user (`({UserNameAttribute}={escaped})`, RFC-escaped), re-bind as user DN to verify password, read `memberOf`. `/login` GET/POST with antiforgery (`DashboardEndpointRouteBuilderExtensions.cs`).
|
||||
- **Group→role:** `MxGateway:Dashboard:GroupToRole` (case-insensitive; tries full DN then leading RDN) → roles `Admin` / `Viewer` (`DashboardRoles.cs`). Zero matched roles ⇒ login denied.
|
||||
- **Cookie:** scheme `MxGateway.Dashboard`; cookie name **`MxGatewayDashboard`** (note: no `__Host-` prefix despite some docs); HttpOnly, SameSite=Strict, 8h sliding; `SecurePolicy` via `MxGateway:Dashboard:RequireHttpsCookie`. Config in `DashboardServiceCollectionExtensions.cs`.
|
||||
- **SignalR hubs** (`/hubs/snapshot|alarms|events`): policy accepts the cookie OR a 30-min Data-Protection bearer minted at `GET /hubs/token` (`HubTokenService.cs`, purpose `…Dashboard.HubToken.v1`; `HubTokenAuthenticationHandler.cs` also reads `access_token` query for WS upgrades).
|
||||
- **Loopback bypass:** `MxGateway:Dashboard:AllowAnonymousLocalhost` (default `true`) — `DashboardAuthorizationHandler.cs`.
|
||||
- **LDAP config:** `Configuration/LdapOptions.cs` → `MxGateway:Ldap:{Enabled, Server, Port(3893), UseTls, AllowInsecureLdap, SearchBase(dc=lmxopcua,dc=local), ServiceAccountDn, ServiceAccountPassword, UserNameAttribute(cn), DisplayNameAttribute, GroupAttribute(memberOf)}`. Dev users/groups in `glauth.md` (incl. the gateway-specific `GwAdmin` group).
|
||||
|
||||
## Secrets & config
|
||||
|
||||
Pepper resolved from config (`MxGateway:ApiKeyPepper`), never stored with hashes. CLAUDE.md: API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` creds never logged. Docs: `docs/Authentication.md`, `docs/Authorization.md`, `docs/GatewayDashboardDesign.md`, `docs/DesignDecisions.md`, `glauth.md`.
|
||||
|
||||
## Notable limits / TODOs
|
||||
|
||||
EventsHub has no per-session ACL yet (any dashboard user can subscribe to any session); no reconnectable sessions; single event subscriber per session; authz is scope+constraint, not per-item ACL.
|
||||
|
||||
---
|
||||
|
||||
## Adoption plan → `ZB.MOM.WW.Auth`
|
||||
|
||||
**Replace with the shared library:**
|
||||
- **Model A is the reference implementation for `ZB.MOM.WW.Auth.ApiKeys`.** The whole `Security/Authentication/` key pipeline (parser, hasher, generator, verifier, SQLite store, audit, scope serializer, admin CLI) maps almost 1:1 onto the proposed contract — extract it here first and have ScadaBridge's Inbound API adopt the same package. Token prefix becomes configurable (`mxgw_`).
|
||||
- Model B LDAP: `DashboardAuthenticator`'s bind-then-search → `ZB.MOM.WW.Auth.Ldap`; cookie/claim wiring → `ZB.MOM.WW.Auth.AspNetCore`. Migrate `MxGateway:Ldap:*` to the canonical config schema.
|
||||
|
||||
**Keep bespoke:**
|
||||
- gRPC **scope** catalog + `GatewayGrpcAuthorizationInterceptor` + constraint globs (domain authz).
|
||||
- Dashboard `Admin`/`Viewer` role meaning, hub-token specifics, loopback bypass.
|
||||
|
||||
**Watch:** align the API-key `MaxWriteClassification`/constraint model with ScadaBridge's per-method approval when extracting — they are different shapes of "scope a key"; the shared contract should carry constraints as an opaque, project-supplied policy rather than hard-coding either.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Auth — current state: OtOpcUa
|
||||
|
||||
Repo: `~/Desktop/OtOpcUa` (Gitea `lmxopcua`). Stack: .NET 10, OPC Foundation UA stack.
|
||||
All paths below are relative to the repo root. Verified against source on 2026-06-01.
|
||||
|
||||
OtOpcUa has the richest auth of the three: OPC UA session-level identity, LDAP-backed
|
||||
authentication, transport security profiles, and a trie-based per-operation ACL system,
|
||||
plus a separate control-plane (Admin UI) auth stack.
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
Three OPC UA identity-token types are accepted at session establishment
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`, impersonation handler ~226–288):
|
||||
|
||||
- **Anonymous** — passes through without custom validation; gets no LDAP groups.
|
||||
- **UserName/password (LDAP-backed)** — the primary human path (see below).
|
||||
- **X.509 certificate** — validated at the secure-channel/PKI level; CN→role mapping not yet implemented.
|
||||
|
||||
**UserName flow:** the SDK decrypts the password with the server certificate, then
|
||||
`IOpcUaUserAuthenticator.AuthenticateUserNameAsync()` validates it.
|
||||
- Seam: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs`
|
||||
- Prod impl: `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs` → wraps `ILdapAuthService`.
|
||||
- UserName tokens are **always encrypted** by the SDK (via the server cert) regardless of transport profile, so LDAP login works even on a `None` endpoint.
|
||||
|
||||
**LDAP service** (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/`):
|
||||
- `ILdapAuthService` / `LdapAuthService.cs` — bind-then-search (or direct bind), re-bind as user DN to verify the password, read `memberOf`, strip leading `CN=`. GLAuth fallback extracts the primary group from the `ou=` RDN.
|
||||
- `LdapOptions.cs` — bound from config section **`Authentication.Ldap`**. Keys: `Enabled`, `Server`, `Port` (GLAuth `3893`), `UseTls`, `AllowInsecureLdap`, `SearchBase` (`dc=lmxopcua,dc=local`), `ServiceAccountDn`, `ServiceAccountPassword`, `UserNameAttribute` (`cn`; AD→`sAMAccountName`), `GroupAttribute` (`memberOf`), `DisplayNameAttribute`, `GroupToRole` (dict), `DevStubMode` (dev-only: accepts any non-empty creds).
|
||||
- `RoleMapper.cs` — maps LDAP groups → control-plane `AdminRole` via `GroupToRole`.
|
||||
- Dev LDAP server: GLAuth at `C:\publish\glauth\` (see `C:\publish\glauth\auth.md`).
|
||||
|
||||
## 2. Transport security
|
||||
|
||||
`OpcUaServer` config section; `EnabledSecurityProfiles` (default `[None, Basic256Sha256-Sign, Basic256Sha256-SignAndEncrypt]`; Aes128/Aes256 profiles also available), resolved by `SecurityProfileResolver` at startup. Server certificate auto-created under `PkiStoreRoot` (`own/ issuer/ trusted/ rejected/`). `AutoAcceptUntrustedClientCertificates` (default `false`). See `docs/security.md`.
|
||||
- Profile enum + policy build: `OpcUaApplicationHost.cs` (~15–23, ~374–410).
|
||||
|
||||
## 3. Authorization (data-plane ACLs — stays bespoke)
|
||||
|
||||
Bitmask permissions in `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs`:
|
||||
`Browse, Read, Subscribe, HistoryRead, WriteOperate, WriteTune, WriteConfigure, AlarmRead,
|
||||
AlarmAcknowledge, AlarmConfirm, AlarmShelve, MethodCall` + bundles `ReadOnly/Operator/Engineer/Admin`.
|
||||
The three write tiers mirror Galaxy `SecurityClassification` (Operate/Tune/Configure).
|
||||
|
||||
- Scope hierarchy: `NodeAclScopeKind.cs` — `Cluster, Namespace, UnsArea, UnsLine, Equipment, FolderSegment, Tag`.
|
||||
- Grant entity: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs` — `(LdapGroup, ScopeKind, ScopeId, PermissionFlags)`, generation-versioned.
|
||||
- Evaluation: `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — `TriePermissionEvaluator.cs`, `PermissionTrie.cs`, `PermissionTrieCache.cs`, `PermissionTrieBuilder.cs`, `IPermissionEvaluator.cs`. Per-operation (`OpcUaOperation` enum); denials return `BadUserAccessDenied`.
|
||||
- Additive-only grants (no Deny) in Phase 6.2.
|
||||
|
||||
**Control-plane (Admin UI) roles are independent** of data-plane ACLs (design decision #150):
|
||||
`AdminRole` enum (`ConfigViewer / ConfigEditor / FleetAdmin`); `LdapGroupRoleMapping` entity maps groups→AdminRole. Cookie + JWT stack lives in `src/Server/ZB.MOM.WW.OtOpcUa.Security/` (`ServiceCollectionExtensions.cs`, `Endpoints/AuthEndpoints.cs` — `/login`,`/logout`,`/ping`; `Jwt/JwtTokenService.cs`; `Blazor/CookieAuthenticationStateProvider.cs`). DataProtection keys persisted in the Config DB so cookies survive failover.
|
||||
|
||||
## 4. Session / identity model (stays bespoke)
|
||||
|
||||
`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs`:
|
||||
`SessionId, ClusterId, LdapGroups, MembershipResolvedUtc, AuthGenerationId, MembershipVersion`,
|
||||
`MembershipFreshnessInterval` (5 min, async refresh) and `AuthCacheMaxStaleness` (15 min, fail-closed).
|
||||
Sessions are **generation-bound** at sign-in so grant changes can't take effect mid-session.
|
||||
Auth is evaluated **per request**, not cached per session.
|
||||
|
||||
## 5. Secrets & config
|
||||
|
||||
`Authentication.Ldap.ServiceAccountPassword` should come from user-secrets/env, not source.
|
||||
DataProtection keys in Config DB table `DataProtectionKeys`. Docs: `docs/security.md`, `docs/v2/acl-design.md`.
|
||||
|
||||
## 6. Notable limits / TODOs
|
||||
|
||||
No explicit Deny grants (Phase 6.2); no nested-group expansion (relies on directory flattening);
|
||||
LDAP unreachable >15 min fails all sessions closed; X.509 CN→role mapping deferred;
|
||||
`HistoryUpdate` currently mapped to the `HistoryRead` bit.
|
||||
|
||||
---
|
||||
|
||||
## Adoption plan → `ZB.MOM.WW.Auth`
|
||||
|
||||
**Replace with the shared library:**
|
||||
- `LdapAuthService` + `LdapOptions` + `RoleMapper` → `ZB.MOM.WW.Auth.Ldap` (`ILdapAuthService`, `LdapAuthService`, `LdapOptions`, `LdapAuthResult`) + `IGroupRoleMapper<CanonicalRole>`; OtOpcUa expands each canonical role into its `AdminRole` (control-plane) and `NodePermissions` (data-plane) per [`../../spec/CANONICAL-ROLES.md`](../../spec/CANONICAL-ROLES.md). No first-class `Deployer` (publish ⊂ `FleetAdmin`).
|
||||
- Migrate config from `Authentication.Ldap.*` to the canonical schema in [`../../spec/SPEC.md`](../../spec/SPEC.md) (notably `UseTls` → the canonical transport setting; `UserNameAttribute` keeps its name as the canonical one).
|
||||
- **Control-plane Admin UI** (Blazor, `src/Server/ZB.MOM.WW.OtOpcUa.Security/`): adopt `ZB.MOM.WW.Auth.AspNetCore` for the canonical cookie/claim conventions + DI helpers (`ServiceCollectionExtensions.cs`, `Blazor/CookieAuthenticationStateProvider.cs`, `Endpoints/AuthEndpoints.cs`). This is OtOpcUa's HTTP auth surface — distinct from the OPC UA data plane below.
|
||||
|
||||
**Keep bespoke (thin adapter only):**
|
||||
- `IOpcUaUserAuthenticator` / `LdapOpcUaUserAuthenticator` — keep as the OPC-UA-specific adapter that calls the shared `ILdapAuthService`.
|
||||
- ALL of §3 authZ (`NodePermissions`, ACL trie, `NodeAcl`), the control-plane `AdminRole` vocabulary, the JWT/`DataProtection` specifics, and §4 session model — domain-specific, not extracted (only the cookie/claim *conventions* are shared via `.AspNetCore`).
|
||||
- Transport security (§2) — OPC-UA-specific.
|
||||
|
||||
**No API-key work** — OtOpcUa has no API-key surface; it relies on OPC UA transport security instead.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Auth — current state: ScadaBridge
|
||||
|
||||
Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`.
|
||||
Auth code centers on the `ZB.MOM.WW.ScadaBridge.Security` project. All paths relative to repo root.
|
||||
Verified 2026-06-01.
|
||||
|
||||
LDAP-centric identity with database-driven role mapping and **site-scoped** deployment;
|
||||
multiple authenticated surfaces (Blazor UI, CLI, Management API, Inbound API).
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.Security/`:
|
||||
- `LdapAuthService.cs` — `AuthenticateAsync(username,password)` → `LdapAuthResult`. Direct LDAP bind (no Kerberos/NTLM). RFC 4514 DN escaping + LDAP filter escaping (injection-safe), username trim-normalization, per-operation socket timeout, distinct exception for service-account-bind failure vs bad user creds, **fails login if the group lookup fails** (won't admit with zero roles).
|
||||
- `SecurityOptions.cs` (config section **`ScadaBridge:Security`**, flat keys): `LdapServer`, `LdapPort`, `LdapTransport` (**enum** `Ldaps`/`StartTls`/`None`), `AllowInsecureLdap`, `LdapSearchBase` (`dc=scadabridge,dc=local`), `LdapServiceAccountDn`, `LdapServiceAccountPassword`, `LdapUserIdAttribute` (`uid`; AD→`sAMAccountName`), `LdapDisplayNameAttribute`, `LdapGroupAttribute` (`memberOf`), `LdapConnectionTimeoutMs`, plus `JwtSigningKey`, `JwtExpiryMinutes` (15), `IdleTimeoutMinutes` (30), `JwtRefreshThresholdMinutes` (5), `RequireHttpsCookie`.
|
||||
- `SecurityOptionsValidator.cs` — startup fail-fast if `LdapServer`/`LdapSearchBase` empty; `JwtSigningKey` ≥ 32 bytes.
|
||||
- Dev LDAP: GLAuth in `infra/glauth/config.toml`, base `dc=scadabridge,dc=local`, groups `SCADA-Admins/Designers/Deploy-All/Deploy-SiteA`, users incl. `multi-role` / `admin` (pw `password`).
|
||||
|
||||
## 2. Authorization (roles + site scope — stays bespoke)
|
||||
|
||||
- Roles (`Security/Roles.cs`): `Admin`, `Design`, `Deployment`, `Audit`, `AuditReadOnly`.
|
||||
- **Group→role is DB-driven** (not config): entity `Commons/Entities/Security/LdapGroupMapping.cs` (`LdapGroupName`→`Role`), repo `ConfigurationDatabase/Repositories/SecurityRepository.cs`, EF map `Configurations/SecurityConfiguration.cs`. `Security/RoleMapper.cs` → `MapGroupsToRolesAsync` returns roles + permitted site IDs + `isSystemWideDeployment` (union semantics: any unscoped Deployment mapping ⇒ system-wide).
|
||||
- **Site-scoping:** `Commons/Entities/Security/SiteScopeRule.cs` (`LdapGroupMappingId`→`SiteId`). No scope rules on a Deployment mapping ⇒ all sites.
|
||||
- ASP.NET policies (`Security/AuthorizationPolicies.cs`): `RequireAdmin/RequireDesign/RequireDeployment/OperationalAudit/AuditExport`.
|
||||
|
||||
## 3. Authenticated surfaces
|
||||
|
||||
| Surface | Entry | Mechanism | Role check | Site scope |
|
||||
|---|---|---|---|---|
|
||||
| Central UI (Blazor Server) | `/auth/login` form | LDAP → cookie | `[Authorize(Policy=…)]`, `AuthorizeView` | `CentralUI/Auth/SiteScopeService.cs` |
|
||||
| CLI | `--username/--password` | HTTP Basic → Management API → LDAP | at Management API | at Management API |
|
||||
| Management API | `POST /management` | HTTP Basic → LDAP | `ManagementActor.GetRequiredRole` then `Roles.Contains` | `ManagementActor.EnforceSiteScope` |
|
||||
| Inbound API | `X-API-Key` header | **API key** hash lookup (not LDAP) | per-method approval in DB | n/a |
|
||||
| Central↔Site | Akka ClusterClient + gRPC | none (cluster membership is the trust boundary; TLS in prod) | — | — |
|
||||
|
||||
Key files: `CentralUI/Auth/AuthEndpoints.cs` (`/auth/login`,`/auth/token`,`/auth/logout`,`/auth/ping`), `CentralUI/Auth/CookieAuthenticationStateProvider.cs`, `ManagementService/ManagementEndpoints.cs` + `ManagementActor.cs`, `CLI/ManagementHttpClient.cs`, `InboundAPI/ApiKeyValidator.cs`.
|
||||
|
||||
## 4. Session / identity model
|
||||
|
||||
Cookie auth (`Security/ServiceCollectionExtensions.cs`): cookie name `ZB.MOM.WW.ScadaBridge.Auth`, HttpOnly, SameSite=Strict, Secure conditional, sliding `IdleTimeoutMinutes` (30). Claims carry name/display/username/roles/site-ids. Separate **JWT** (`Security/JwtTokenService.cs`, HMAC-SHA256, issuer/aud `scadabridge-central`, 15-min expiry, refresh at 5-min threshold, idle-timeout enforced) used for programmatic/CLI via `/auth/token`. Shared `JwtSigningKey` across central nodes ⇒ no sticky sessions. Note: the cookie is the ASP.NET session token; JWT is *not* embedded in it.
|
||||
|
||||
**Inbound API keys** (`InboundAPI/ApiKeyValidator.cs`): `X-API-Key`, peppered HMAC-SHA256, **constant-time** compare, per-method approval, indistinguishable 403 for "no method"/"not approved".
|
||||
|
||||
## 5. Secrets & config
|
||||
|
||||
`ldap_login.txt` / `sql_login.txt` (real creds, git-ignored). Docker config in `docker/central-node-*/appsettings.Central.json`. Config hierarchy: `appsettings.json` → `appsettings.{Central|Site}.json` → env → CLI. Spec: `docs/requirements/Component-Security.md`.
|
||||
|
||||
## 6. Notable limits / TODOs
|
||||
|
||||
No Kerberos/NTLM; no dynamic role refresh (only on token expiry/LDAP re-query); no forced session revocation; `AddSecurityActors()` is a placeholder; no MFA; no password-reset (AD-managed).
|
||||
|
||||
---
|
||||
|
||||
## Adoption plan → `ZB.MOM.WW.Auth`
|
||||
|
||||
**Replace with the shared library:**
|
||||
- `LdapAuthService` + `SecurityOptions` (LDAP portion) + `LdapAuthResult` → `ZB.MOM.WW.Auth.Ldap`. ScadaBridge's escaping/timeout/fail-closed hygiene is strong — fold it into the shared impl. Reconcile config: flat `ScadaBridge:Security:Ldap*` + `LdapTransport` enum vs the canonical schema in [`../../spec/SPEC.md`](../../spec/SPEC.md) (canonical uses the transport **enum**, which ScadaBridge already has — adopt ScadaBridge's `LdapTransport` shape, rename keys to canonical).
|
||||
- `InboundAPI/ApiKeyValidator.cs` → `ZB.MOM.WW.Auth.ApiKeys` (same peppered-HMAC contract mxaccessgw extracts). Map "per-method approval" onto the contract's opaque constraint/policy hook.
|
||||
- Cookie/JWT wiring → `ZB.MOM.WW.Auth.AspNetCore` (claims + cookie conventions). `JwtTokenService` is a candidate for a shared token helper if mxaccessgw/OtOpcUa want the same refresh model — otherwise keep bespoke.
|
||||
|
||||
**Keep bespoke:**
|
||||
- Native role set (`Admin/Design/Deployment/Audit/AuditReadOnly`) + **site-scoping** (`SiteScopeRule`, union semantics) stay as the enforcement layer; implement `IGroupRoleMapper<CanonicalRole>` over the DB mapping and expand canonical→native. Per [`../../spec/CANONICAL-ROLES.md`](../../spec/CANONICAL-ROLES.md): `AuditReadOnly`→Viewer, `Audit`→Administrator (SoD collapse); no `Operator`/`Engineer`.
|
||||
- `ManagementActor` role/scope enforcement, Akka cluster trust model.
|
||||
|
||||
**Note the group→role *mechanism* divergence:** ScadaBridge maps groups→roles in the **database**; OtOpcUa and mxaccessgw map in **config**. The shared seam (`IGroupRoleMapper`) must allow either backing store — see [`../../GAPS.md`](../../GAPS.md).
|
||||
@@ -0,0 +1,128 @@
|
||||
# Proposed shared library: `ZB.MOM.WW.Auth`
|
||||
|
||||
A contract on paper — the public surface to extract so the three projects stop
|
||||
re-implementing identity + API-key auth. Realizes [`../spec/SPEC.md`](../spec/SPEC.md).
|
||||
**Not yet created.** Reference implementations already exist: mxaccessgw Model A (API keys),
|
||||
ScadaBridge `LdapAuthService` (LDAP hygiene).
|
||||
|
||||
## Packages (.NET 10)
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Auth.Abstractions # interfaces, options, result records — the stable surface
|
||||
ZB.MOM.WW.Auth.Ldap # bind-then-search authn (§2)
|
||||
ZB.MOM.WW.Auth.ApiKeys # peppered-HMAC key auth + SQLite store (§4)
|
||||
ZB.MOM.WW.Auth.AspNetCore # cookie/claim/DI helpers (§5) — OtOpcUa Admin UI, gateway, ScadaBridge
|
||||
```
|
||||
|
||||
All four are .NET 10, which all auth-bearing processes are (OtOpcUa server, mxaccessgw
|
||||
**gateway**, ScadaBridge central) — the x86/net48 mxaccessgw worker does no auth, so net48
|
||||
multi-targeting is **not** required. Published to the Gitea NuGet feed; SemVer; one consumer
|
||||
bump per release.
|
||||
|
||||
## Packaging & distribution
|
||||
|
||||
**Four NuGet packages, one DLL each**, on the Gitea NuGet feed, lockstep SemVer to start
|
||||
(one version across all four; split to independent versions only if churn diverges). These
|
||||
are **libraries** linked into each app and copied to its own `bin/` — there is **no central
|
||||
auth service**. The repos stay separate processes sharing *code*, not a runtime dependency
|
||||
(auth must run in-process anyway: OPC UA SDK callback, gRPC interceptor, ASP.NET middleware).
|
||||
Consumers reference only what they need:
|
||||
|
||||
| Package (→ DLL) | Transitive deps | OtOpcUa | mxaccessgw | ScadaBridge |
|
||||
|---|---|---|---|---|
|
||||
| `…Auth.Abstractions` | none | ✅ | ✅ | ✅ |
|
||||
| `…Auth.Ldap` | LDAP client (e.g. `System.DirectoryServices.Protocols`) | ✅ | ✅ | ✅ |
|
||||
| `…Auth.ApiKeys` | `Microsoft.Data.Sqlite` | — | ✅ | ✅ |
|
||||
| `…Auth.AspNetCore` | ASP.NET Core | ✅ (Admin UI) | ✅ | ✅ |
|
||||
|
||||
**Why OtOpcUa *does* take `.AspNetCore`:** it has two auth surfaces. Its OPC UA **data plane**
|
||||
(UserName tokens via the SDK impersonation callback + ACL trie) is not HTTP and uses only
|
||||
`.Ldap` + `.Abstractions` behind a bespoke `IOpcUaUserAuthenticator`. But its Blazor **Admin
|
||||
UI control plane** (cookie + JWT + DataProtection + authorization policies) *is* ASP.NET Core,
|
||||
so it shares the canonical claim/cookie conventions from `.AspNetCore`. Both surfaces share
|
||||
`.Ldap` for the bind. (`.ApiKeys` is the only package OtOpcUa skips — it has no API-key surface.)
|
||||
|
||||
## `ZB.MOM.WW.Auth.Abstractions`
|
||||
|
||||
```csharp
|
||||
public sealed record LdapOptions { // §1 canonical schema
|
||||
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 LdapTransport { Ldaps, StartTls, None }
|
||||
|
||||
public sealed record LdapAuthResult( // outcome of authn
|
||||
bool Succeeded, string Username, string DisplayName,
|
||||
IReadOnlyList<string> Groups, LdapAuthFailure? Failure);
|
||||
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
|
||||
|
||||
public interface ILdapAuthService { // §2
|
||||
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct);
|
||||
}
|
||||
|
||||
public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator } // ../spec/CANONICAL-ROLES.md
|
||||
|
||||
public interface IGroupRoleMapper<TRole> { // §3 — TRole defaults to CanonicalRole; backing store stays per-project
|
||||
Task<GroupRoleMapping<TRole>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct);
|
||||
}
|
||||
public sealed record GroupRoleMapping<TRole>(IReadOnlyList<TRole> Roles, object? Scope);
|
||||
// Each project expands a CanonicalRole into its native permissions/scopes at enforcement time.
|
||||
```
|
||||
|
||||
## `ZB.MOM.WW.Auth.ApiKeys`
|
||||
|
||||
```csharp
|
||||
public sealed record ApiKeyOptions { // §4
|
||||
public string TokenPrefix { get; init; } = "mxgw"; // configurable per project
|
||||
public string PepperSecretName { get; init; } = ""; // resolved from secret store, never stored
|
||||
public string SqlitePath { get; init; } = "";
|
||||
public bool RunMigrationsOnStartup { get; init; } = true;
|
||||
}
|
||||
public interface IApiKeyVerifier {
|
||||
Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct);
|
||||
}
|
||||
public sealed record ApiKeyVerification(bool Succeeded, ApiKeyIdentity? Identity, ApiKeyFailure? Failure);
|
||||
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
|
||||
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
|
||||
|
||||
public interface IApiKeyStore { // default: SQLite (hash, scopes, constraints, audit)
|
||||
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct);
|
||||
Task MarkUsedAsync(string keyId, CancellationToken ct);
|
||||
}
|
||||
public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */ }
|
||||
```
|
||||
|
||||
- Constraints are carried as an **opaque `object`** (project supplies the policy: mxaccessgw
|
||||
globs/classification, ScadaBridge per-method approval). The library does the
|
||||
parse→lookup→peppered-HMAC→constant-time-compare→audit pipeline; it does **not** interpret constraints.
|
||||
- Ships the `apikey` admin verbs as a reusable command set.
|
||||
|
||||
## `ZB.MOM.WW.Auth.AspNetCore`
|
||||
|
||||
- Canonical `ClaimTypes` constants (name, display, username, role, scope-id).
|
||||
- Cookie defaults per §5 (HttpOnly, SameSite=Strict, configurable Secure, sliding idle).
|
||||
- DI helpers: `AddZbLdapAuth(IConfiguration)`, `AddZbApiKeyAuth(IConfiguration)`.
|
||||
|
||||
## What stays in each consumer
|
||||
|
||||
OtOpcUa: `IOpcUaUserAuthenticator` adapter, ACL trie, transport security, session model.
|
||||
mxaccessgw: gRPC scope catalog + interceptor, constraint globs, hub tokens.
|
||||
ScadaBridge: role set + site-scoping, `ManagementActor` enforcement, JWT refresh policy.
|
||||
|
||||
## Open contract questions
|
||||
|
||||
1. **Group→role store** must support both config and DB backings without leaking either (the `object? Scope` payload covers site-scoping). Validate against ScadaBridge's union semantics.
|
||||
2. **JWT/refresh**: shared helper or per-project? Only ScadaBridge has the 15-min refresh model today; OtOpcUa has cookie+JWT control plane. Decide when 2+ projects want the same shape.
|
||||
3. **Constraint opacity**: confirm the `object?` boundary is enough, or whether a small `IConstraintPolicy` interface is cleaner.
|
||||
|
||||
See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Canonical roles (standardized)
|
||||
|
||||
Status: **Standardized**. The org-wide role set every sister project maps onto. This is a
|
||||
mapping **layer above** each project's native authorization: native *enforcement* (OtOpcUa
|
||||
`NodePermissions`, mxaccessgw gRPC scopes, ScadaBridge native roles + site-scoping) is
|
||||
unchanged — each project adds a `canonical → native` expansion via the `IGroupRoleMapper<CanonicalRole>`
|
||||
seam ([`SPEC.md`](SPEC.md) §3). LDAP groups (or API keys) are assigned a **canonical** role;
|
||||
the project translates it to native permissions at runtime.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Meaning |
|
||||
|---|---|
|
||||
| `OBSERVE` | browse / read / subscribe / history **+ read audit logs** |
|
||||
| `OPERATE` | operate-level writes; alarm acknowledge / confirm |
|
||||
| `TUNE` | tune-level writes; alarm shelve; method calls |
|
||||
| `AUTHOR` | create / edit configuration & templates |
|
||||
| `DEPLOY` | publish / push configuration to runtime / sites (scoped) |
|
||||
| `ADMINISTER` | manage users / security / system **+ export audit** |
|
||||
|
||||
There is no standalone `AUDIT` capability and **no Auditor role**: audit *read* is part of
|
||||
`OBSERVE`, audit *export* is part of `ADMINISTER`.
|
||||
|
||||
## The six roles (capability bundles)
|
||||
|
||||
| Role | OBSERVE | OPERATE | TUNE | AUTHOR | DEPLOY | ADMINISTER |
|
||||
|---|:-:|:-:|:-:|:-:|:-:|:-:|
|
||||
| **Viewer** | ✓ | | | | | |
|
||||
| **Operator** | ✓ | ✓ | | | | |
|
||||
| **Engineer** | ✓ | ✓ | ✓ | | | |
|
||||
| **Designer** | ✓ | | | ✓ | | |
|
||||
| **Deployer** | ✓ | | | | ✓ | |
|
||||
| **Administrator** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
Two privilege axes meet at the top: **operations** (Viewer → Operator → Engineer) and
|
||||
**configuration lifecycle** (Viewer → Designer → Deployer → Administrator). Administrator is
|
||||
the only role that holds every capability.
|
||||
|
||||
## Orthogonal modifiers (not roles)
|
||||
|
||||
- **Scope** — a grant is `role × scope`. OtOpcUa scopes by OPC-UA node tree
|
||||
(cluster→area→line→equipment→tag); ScadaBridge scopes `Deployer` by **site**; mxaccessgw
|
||||
scopes API keys by subtree/tag globs. The canonical layer carries an abstract scope selector;
|
||||
each project resolves it to its own granularity.
|
||||
- **Principal type** — humans (LDAP group → canonical role) and machines (API key → a subset of
|
||||
canonical capabilities) both take canonical roles.
|
||||
|
||||
## Mapping to each project
|
||||
|
||||
| Canonical | OtOpcUa | MxAccessGateway | ScadaBridge |
|
||||
|---|---|---|---|
|
||||
| **Viewer** | data-plane `ReadOnly` bundle; ctrl `ConfigViewer` | dashboard `Viewer`; scopes `invoke:read`+`metadata:read`+`events:read`+`session:*` | `AuditReadOnly` (← audit read); ⚠ no general config-viewer role yet |
|
||||
| **Operator** | data-plane `Operator` bundle | scope `invoke:write` | ⚠ N/A — runtime ops live at sites, not a central role |
|
||||
| **Engineer** | data-plane `Engineer` bundle | scope `invoke:secure` (closest) | ⚠ N/A |
|
||||
| **Designer** | ctrl `ConfigEditor` (+ `WriteConfigure`) | ⚠ N/A — no config-authoring surface | `Design` |
|
||||
| **Deployer** | ◑ part of `FleetAdmin` (config publish/generation) | ⚠ N/A | `Deployment` (+ site scope) |
|
||||
| **Administrator** | ctrl `FleetAdmin`; data-plane `Admin` bundle | dashboard `Admin`; scope `admin` | `Admin` (+ `Audit` export ←) |
|
||||
|
||||
## Consequences & gaps (accepted)
|
||||
|
||||
- **Auditor removed:** ScadaBridge `AuditReadOnly` → **Viewer**, `Audit` → **Administrator**.
|
||||
This **loses ScadaBridge's auditor/admin separation-of-duties** (an auditor who can export but
|
||||
is not a full admin no longer has a distinct role). Accepted as the cost of standardizing.
|
||||
- Each project implements only the **subset** of canonical roles that applies; the ⚠ cells are
|
||||
simply never assigned there (ScadaBridge: no Operator/Engineer; mxaccessgw: no Designer/Deployer).
|
||||
- OtOpcUa has no first-class Deployer (config publish ⊂ `FleetAdmin`) — `Deployer` maps partially
|
||||
until/unless OtOpcUa splits a publish-only control-plane role.
|
||||
- Adoption is governance + a mapping layer, not enforcement changes — see [`GAPS.md`](../GAPS.md) backlog.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Auth — normalized target spec
|
||||
|
||||
Status: **Draft**. The single design the sister projects converge on. Derived from the
|
||||
three code-verified current-state docs (`../current-state/`). Goal is *path to shared code*
|
||||
(`../shared-contract/ZB.MOM.WW.Auth.md`), so each normalized section maps to a shared library seam.
|
||||
|
||||
## 0. Scope
|
||||
|
||||
**Normalized here:** LDAP/identity config schema; bind-then-search behavior; the
|
||||
group→role mapping *seam*; **the standardized canonical role set every project maps onto
|
||||
([`CANONICAL-ROLES.md`](CANONICAL-ROLES.md))**; the API-key contract; cookie/claim conventions;
|
||||
dev conventions; secret handling.
|
||||
|
||||
**Explicitly NOT normalized** (domain-specific — keep per project): the authorization
|
||||
*enforcement* (OtOpcUa `NodePermissions` ACL trie, mxaccessgw gRPC scopes + constraints,
|
||||
ScadaBridge native roles + site-scoping) — each project **maps its native model onto the
|
||||
canonical roles** but keeps its own enforcement; OPC UA transport security; OtOpcUa's
|
||||
generation/staleness session model; ScadaBridge's site-scope rules; mxaccessgw's hub-token model.
|
||||
|
||||
## 1. LDAP / identity configuration schema
|
||||
|
||||
One **nested `Ldap` options object**, bound under each app's own root section
|
||||
(`OtOpcUa:Authentication:Ldap`, `MxGateway:Ldap`, `ScadaBridge:Security:Ldap`). Canonical keys/types:
|
||||
|
||||
| Key | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Enabled` | bool | |
|
||||
| `Server` | string | host |
|
||||
| `Port` | int | 389 / 636 / 3893 (GLAuth dev) |
|
||||
| `Transport` | enum `Ldaps` \| `StartTls` \| `None` | **adopt ScadaBridge's enum** over a `UseTls` bool — it expresses StartTLS, which the bool can't |
|
||||
| `AllowInsecure` | bool (default false) | dev-only escape hatch; pairs with `Transport=None` |
|
||||
| `SearchBase` | string | base DN |
|
||||
| `ServiceAccountDn` | string | for bind-then-search |
|
||||
| `ServiceAccountPassword` | string | from secret store, never source |
|
||||
| `UserNameAttribute` | string | canonical name (default `cn` GLAuth / `sAMAccountName` AD). Supersedes ScadaBridge's `LdapUserIdAttribute` |
|
||||
| `DisplayNameAttribute` | string | default `cn` |
|
||||
| `GroupAttribute` | string | default `memberOf` |
|
||||
| `ConnectionTimeoutMs` | int | per-operation socket timeout |
|
||||
|
||||
Migration: OtOpcUa `Authentication.Ldap.UseTls`→`Transport`; ScadaBridge flat `Ldap*`→nested + rename `LdapUserIdAttribute`→`UserNameAttribute`; gateway `MxGateway:Ldap` already close.
|
||||
|
||||
## 2. Bind-then-search behavior (canonical algorithm)
|
||||
|
||||
1. Connect to `Server:Port`. If `Transport != Ldaps/StartTls` and not `AllowInsecure` → **refuse** (config error).
|
||||
2. Bind the **service account**; a failure here is a *system misconfiguration*, surfaced distinctly from bad user creds.
|
||||
3. Search under `SearchBase` with filter `({UserNameAttribute}={escapedUsername})`. **Escape** the username (LDAP filter rules: `\ * ( ) NUL`). Reject **multiple** matches (ambiguous); no match → auth failure.
|
||||
4. **Re-bind as the resolved user DN** with the supplied password to verify it. RFC 4514-escape DN components.
|
||||
5. Read `GroupAttribute`; normalize group names (strip leading `CN=`/RDN). **If the group lookup fails, fail the login** — never admit a user with zero resolved groups.
|
||||
6. **Trim-normalize** the username once at entry so one person ≠ two identities.
|
||||
|
||||
Generic, injection-safe, fail-closed. (ScadaBridge's current impl is the closest reference.)
|
||||
|
||||
## 3. Group → role mapping seam
|
||||
|
||||
Resolved LDAP groups (and API-key principals) map to the **standardized canonical role set**
|
||||
([`CANONICAL-ROLES.md`](CANONICAL-ROLES.md)) via `IGroupRoleMapper<CanonicalRole>`; each project
|
||||
then **expands a canonical role into its native permissions/scopes** and applies its own scope
|
||||
payload. The backing store is project-chosen — **config dict** (OtOpcUa `GroupToRole`, gateway
|
||||
`Dashboard:GroupToRole`) **or database** (ScadaBridge `LdapGroupMapping`). The shared library
|
||||
defines the seam + the `CanonicalRole` enum and ships both a config-backed and a delegate/DB-backed
|
||||
mapper; native enforcement stays per-project.
|
||||
|
||||
## 4. API-key contract (machine-to-machine)
|
||||
|
||||
For projects with a programmatic surface (mxaccessgw gRPC, ScadaBridge Inbound API):
|
||||
|
||||
- **Token:** `<prefix>_<keyId>_<secret>` (prefix configurable, e.g. `mxgw`). Parsed/validated before any store hit.
|
||||
- **Hashing:** HMAC-SHA256 with an **external pepper** (from secret store/config, never stored beside the hash). Secret = 32 random bytes, URL-safe base64.
|
||||
- **Verify:** lookup by keyId → reject if revoked → hash presented secret with pepper → **constant-time compare** (`CryptographicOperations.FixedTimeEquals`). Discriminated failure reasons for audit; opaque error to the caller.
|
||||
- **Store:** abstraction with a default SQLite implementation (hash, scopes, optional constraints, created/last-used/revoked) + **append-only audit**. Revoke = timestamp; delete only when revoked.
|
||||
- **Scopes:** a `string` set gating operations (project supplies the catalog).
|
||||
- **Constraints:** an **opaque, project-supplied policy** object (mxaccessgw subtree/tag globs + `MaxWriteClassification`; ScadaBridge per-method approval). The contract carries it; it does not hard-code either shape.
|
||||
- **Admin CLI:** `init-db / create-key / list-keys / revoke-key / rotate-key / delete-key`.
|
||||
|
||||
(mxaccessgw Model A is the reference implementation to extract.)
|
||||
|
||||
## 5. Cookie / claim / session conventions
|
||||
|
||||
- **Cookie:** HttpOnly, `SameSite=Strict`, `Secure` configurable for dev (`RequireHttpsCookie`), sliding idle expiry. Name pattern `<App>.Auth`.
|
||||
- **Claims:** canonical claim types for name, display name, username, role (one per role), and any project scope id — defined once in `ZB.MOM.WW.Auth.AspNetCore`.
|
||||
- **DataProtection** keys persisted to shared storage so cookies/tokens survive node failover (OtOpcUa already does this via Config DB).
|
||||
- Session *lifetime policy* (refresh windows, staleness, generation binding) stays per-project.
|
||||
|
||||
## 6. Dev & secret conventions
|
||||
|
||||
- **Dev directory:** GLAuth. **Unify the dev base DN** — today OtOpcUa/gateway use `dc=lmxopcua,dc=local` and ScadaBridge uses `dc=scadabridge,dc=local`; pick one shared dev base DN (see `../GAPS.md`).
|
||||
- **Dev escape hatches** named consistently: `AllowInsecure` (LDAP), plus each project's documented bypass (`AllowAnonymousLocalhost`, `DevStubMode`) clearly dev-only and off in prod.
|
||||
- **Secrets:** service-account passwords, JWT signing keys, and API-key peppers come from a secret store / env, never source; never logged (API keys, passwords, secured payloads, credentials).
|
||||
|
||||
## 7. Acceptance (what "converged" means)
|
||||
|
||||
A project is converged when: (a) its LDAP authn + (if applicable) API-key auth run on the
|
||||
`ZB.MOM.WW.Auth` packages; (b) its config matches §1's schema; (c) its group→role mapping
|
||||
implements the §3 seam; (d) cookie/claim conventions match §5; with all project-specific
|
||||
authorization unchanged.
|
||||
@@ -0,0 +1,346 @@
|
||||
# ZB.MOM.WW.Auth Shared Library Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build the `ZB.MOM.WW.Auth` shared library set (4 NuGet packages) that normalizes LDAP identity, the canonical role seam, API-key auth, and ASP.NET cookie/claim conventions so OtOpcUa, MxAccessGateway, and ScadaBridge can stop re-implementing auth.
|
||||
|
||||
**Architecture:** A new standalone repo (`~/Desktop/scadaproj/ZB.MOM.WW.Auth`), .NET 10, four library projects with one DLL each — `Abstractions` (pure contracts), `Ldap` (bind-then-search authn), `ApiKeys` (peppered-HMAC keys + SQLite store), `AspNetCore` (cookie/claim/DI helpers). The reference implementations are lifted and generalized: `Ldap` from ScadaBridge's hardened `LdapAuthService`, `ApiKeys` from mxaccessgw's `Security/Authentication` pipeline. Libraries are linked into each consumer and copied to its `bin/` — **no central auth service**. Consumer adoption is a **separate follow-on plan**; this plan delivers the library + tests + packages only.
|
||||
|
||||
**Tech Stack:** .NET 10, C#; xUnit + `Xunit.SkippableFact`; `Novell.Directory.Ldap.NETStandard`; `Microsoft.Data.Sqlite`; `Microsoft.Extensions.{DependencyInjection,Options}`, `Microsoft.AspNetCore.Authentication.*`; central package management (`Directory.Packages.props`); `.slnx` solution.
|
||||
|
||||
**Source references (read-only, to port from):**
|
||||
- LDAP: `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs`, `SecurityOptions.cs`, `SecurityOptionsValidator.cs`
|
||||
- API keys: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/*` (`ApiKeyParser`, `ApiKeySecretHasher`, `ApiKeySecretGenerator`, `ApiKeyVerifier`, `Sqlite*Store`, `SqliteAuthSchema`, `ApiKeyAdmin*`)
|
||||
- Design: `~/Desktop/scadaproj/components/auth/spec/SPEC.md`, `spec/CANONICAL-ROLES.md`, `shared-contract/ZB.MOM.WW.Auth.md`
|
||||
|
||||
**Conventions for every task:** TDD (@superpowers-extended-cc:test-driven-development) — failing test first, minimal impl, green, commit. File-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods. Never log secrets. Commit after each green task.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Scaffold
|
||||
|
||||
### Task 1: Create repo, solution, and project shells
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (everything depends on this)
|
||||
|
||||
**Files:**
|
||||
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx`
|
||||
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/Directory.Build.props`
|
||||
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/Directory.Packages.props`
|
||||
- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/.gitignore`
|
||||
- Create: `src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj`
|
||||
- Create: `src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj`
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj`
|
||||
- Create: `src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj`
|
||||
- Create: `tests/ZB.MOM.WW.Auth.Ldap.Tests/…csproj`, `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/…csproj`, `tests/ZB.MOM.WW.Auth.AspNetCore.Tests/…csproj`
|
||||
|
||||
**Steps:**
|
||||
1. `cd ~/Desktop && mkdir ZB.MOM.WW.Auth && cd ZB.MOM.WW.Auth && git init && dotnet new gitignore`
|
||||
2. `dotnet new sln -n ZB.MOM.WW.Auth --format slnx` (if `.slnx` unsupported by the SDK, use default `.sln`).
|
||||
3. Scaffold projects: `dotnet new classlib -f net10.0 -o src/<Name>` for the 4 libs; `dotnet new xunit -f net10.0 -o tests/<Name>.Tests` for the 3 test projects. Delete the default `Class1.cs`/`UnitTest1.cs`.
|
||||
4. Project refs: `.Ldap`/`.ApiKeys`/`.AspNetCore` → `.Abstractions`; `.AspNetCore` → `.Ldap` + `.ApiKeys`; each test project → its lib (+ `.Abstractions`).
|
||||
5. `Directory.Build.props`: `<TargetFramework>net10.0</TargetFramework>`, `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>`, `<LangVersion>latest</LangVersion>`, `<Version>0.1.0</Version>` (lockstep), `<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>`.
|
||||
6. `Directory.Packages.props`: pin `Novell.Directory.Ldap.NETStandard`, `Microsoft.Data.Sqlite`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.AspNetCore.Authentication.Cookies`/`.JwtBearer`, `Microsoft.AspNetCore.Authorization`, and test packages `xunit`, `xunit.runner.visualstudio`, `Xunit.SkippableFact`, `Microsoft.NET.Test.Sdk`.
|
||||
7. `dotnet sln add` all projects; `dotnet build` to confirm the empty solution compiles.
|
||||
8. **Commit:** `git add -A && git commit -m "chore: scaffold ZB.MOM.WW.Auth solution and projects"`
|
||||
|
||||
**Acceptance:** `dotnet build` green; 7 projects in the solution.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Abstractions (pure contracts)
|
||||
|
||||
### Task 2: Abstractions — all contract types
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (gates all impl tasks)
|
||||
|
||||
These are declarations only (no behavior) → no unit tests; correctness is enforced by compilation and by the impl-task tests that consume them.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapOptions.cs` (+ `LdapTransport`, `LdapAuthResult`, `LdapAuthFailure`, `ILdapAuthService`)
|
||||
- Create: `src/ZB.MOM.WW.Auth.Abstractions/Roles/CanonicalRole.cs` (+ `IGroupRoleMapper<TRole>`, `GroupRoleMapping<TRole>`)
|
||||
- Create: `src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs` (`ApiKeyOptions`, `IApiKeyVerifier`, `ApiKeyVerification`, `ApiKeyIdentity`, `ApiKeyFailure`, `ApiKeyRecord`, `IApiKeyStore`, `IApiKeyAdminStore`, `ApiKeyAuditEntry`)
|
||||
|
||||
**Step 1: Write the types** — transcribe the contract from `components/auth/shared-contract/ZB.MOM.WW.Auth.md` verbatim (it is already valid C#). Canonical enum exactly:
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator }
|
||||
```
|
||||
|
||||
**Step 2: Build** — `dotnet build src/ZB.MOM.WW.Auth.Abstractions`. Expected: success.
|
||||
|
||||
**Step 3: Commit** — `git commit -am "feat(abstractions): auth contracts, canonical roles, api-key types"`
|
||||
|
||||
**Acceptance:** Abstractions compiles; no dependency beyond the BCL.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Ldap (authn) — parallel with Phase 3
|
||||
|
||||
### Task 3: LDAP filter & DN escaping
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 8, Task 9, Task 10
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEscaping.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEscapingTests.cs`
|
||||
|
||||
**Step 1: Failing tests** (port the escaping rules from ScadaBridge `LdapAuthService`):
|
||||
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData("a*b", @"a\2ab")]
|
||||
[InlineData("a(b)", @"a\28b\29")]
|
||||
[InlineData(@"a\b", @"a\5cb")]
|
||||
public void EscapesLdapFilterMetacharacters(string raw, string expected)
|
||||
=> Assert.Equal(expected, LdapEscaping.Filter(raw));
|
||||
|
||||
[Fact]
|
||||
public void EscapesDnPerRfc4514()
|
||||
=> Assert.Equal(@"\#cn\,test", LdapEscaping.Dn("#cn,test"));
|
||||
```
|
||||
|
||||
**Step 2:** `dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests --filter LdapEscapingTests` → FAIL.
|
||||
**Step 3:** Implement `LdapEscaping.Filter` (escape `* ( ) \ NUL` → `\2a \28 \29 \5c \00`) and `LdapEscaping.Dn` (RFC 4514 leading `# space`, and `, + " \ < > ;`).
|
||||
**Step 4:** Re-run → PASS.
|
||||
**Step 5:** `git commit -am "feat(ldap): RFC-4514 / filter escaping"`
|
||||
|
||||
### Task 4: ILdapConnection seam + Novell adapter + fake
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8, Task 9, Task 10
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs` (+ `ILdapConnectionFactory`)
|
||||
- Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs` (wraps `Novell.Directory.Ldap.LdapConnection`)
|
||||
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs` (test double)
|
||||
|
||||
The seam exists so `LdapAuthService` logic is unit-testable without a live server. Methods: `Connect(host,port,transport,ct)`, `Bind(dn,password,ct)`, `Task<IReadOnlyList<LdapEntry>> Search(base,filter,attrs,ct)`, `Dispose`.
|
||||
|
||||
**Steps:** Write `FakeLdapConnection` first (records binds, returns scripted search results, can throw on a given bind to simulate bad creds) → it's the test harness for Task 5/6. Build. Commit `feat(ldap): connection seam + Novell adapter + test fake`.
|
||||
|
||||
### Task 5: LdapAuthService — happy path, transport enforcement, options validation
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8–14 (different project)
|
||||
**Blocked by:** Task 3, Task 4
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs`
|
||||
- Create: `src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs`, `LdapOptionsValidatorTests.cs`
|
||||
|
||||
**Step 1: Failing tests** (inject `FakeLdapConnection`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task SucceedsAndReturnsGroups_OnValidCredentials() {
|
||||
var fake = new FakeLdapConnection()
|
||||
.WithUserEntry("cn=alice,dc=x", memberOf: ["cn=Engineers,...", "cn=Viewers,..."]);
|
||||
var svc = new LdapAuthService(Opts(), _ => fake, NullLogger);
|
||||
var r = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal(["Engineers", "Viewers"], r.Groups); // CN= stripped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_Rejects_PlainLdap_WhenNotAllowInsecure() {
|
||||
var v = new LdapOptionsValidator().Validate(null, Opts(transport: LdapTransport.None, allowInsecure: false));
|
||||
Assert.True(v.Failed);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** run → FAIL.
|
||||
**Step 3:** Implement the bind-then-search flow per SPEC §2: connect (enforce `Transport` unless `AllowInsecure`); service-account bind; search `({UserNameAttribute}={LdapEscaping.Filter(username.Trim())})`; reject 0/≥2 matches; re-bind as user DN with password; read `GroupAttribute`; strip `CN=`. Validator enforces transport + required `Server`/`SearchBase`. Port logic from ScadaBridge `LdapAuthService.cs`.
|
||||
**Step 4:** run → PASS.
|
||||
**Step 5:** `git commit -am "feat(ldap): bind-then-search happy path + options validation"`
|
||||
|
||||
### Task 6: LdapAuthService — failure modes (fail-closed)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8–14
|
||||
**Blocked by:** Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailureTests.cs`
|
||||
|
||||
**Step 1: Failing tests** covering each `LdapAuthFailure`: `BadCredentials` (user bind throws), `UserNotFound` (0 search hits), `AmbiguousUser` (≥2 hits), `GroupLookupFailed` → **must return Failed, never admit with zero groups**, `ServiceAccountBindFailed` (distinct from bad user creds), `Disabled` (Enabled=false). Assert each maps to the right enum and never throws to the caller.
|
||||
**Step 2:** run → FAIL.
|
||||
**Step 3:** Implement discriminated failures; wrap service-account bind failure distinctly; per-operation timeout (`ConnectionTimeoutMs`); username trim-normalization once at entry.
|
||||
**Step 4:** run → PASS.
|
||||
**Step 5:** `git commit -am "feat(ldap): fail-closed failure modes + distinct service-account errors"`
|
||||
|
||||
### Task 7: GLAuth integration test (skippable)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8–14
|
||||
**Blocked by:** Task 6
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs`
|
||||
|
||||
Use `[SkippableFact]` — `Skip.IfNot(GLAuth reachable on localhost:3893)`. Authenticate a known GLAuth user end-to-end through the real `NovellLdapConnection`; assert groups resolve. Document running GLAuth (reuse a sister repo's `infra/glauth`). Commit `test(ldap): skippable GLAuth integration test`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — ApiKeys (machine auth) — parallel with Phase 2
|
||||
|
||||
### Task 8: Token parser + secret generator
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3–7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyParser.cs`, `ApiKeySecretGenerator.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyParserTests.cs`, `ApiKeySecretGeneratorTests.cs`
|
||||
|
||||
**Step 1: Failing tests:** parse `mxgw_alice_SECRET` → `(KeyId="alice", Secret="SECRET")`; reject missing prefix / wrong prefix / malformed; prefix configurable. Generator returns 32-byte URL-safe base64, distinct each call (vary by calling twice).
|
||||
**Steps 2-5:** port from mxaccessgw `ApiKeyParser.cs`/`ApiKeySecretGenerator.cs`; TDD red→green; commit `feat(apikeys): token parser + secret generator`.
|
||||
|
||||
### Task 9: Peppered HMAC hasher + constant-time compare
|
||||
|
||||
**Classification:** small (security-sensitive)
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3–7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretHasher.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretHasherTests.cs`
|
||||
|
||||
**Step 1: Failing tests:** same secret+pepper → identical hash; different pepper → different hash; verification uses `CryptographicOperations.FixedTimeEquals`; missing pepper throws `PepperUnavailable`-style.
|
||||
**Step 3:** HMAC-SHA256 over the secret keyed by the pepper (resolved from `ApiKeyOptions.PepperSecretName` via injected secret provider). Port from mxaccessgw `ApiKeySecretHasher.cs`.
|
||||
**Step 5:** commit `feat(apikeys): peppered HMAC-SHA256 hasher + constant-time compare`.
|
||||
|
||||
### Task 10: SQLite schema + connection factory + migrator
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3–7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/AuthSqliteConnectionFactory.cs`, `SqliteAuthSchema.cs`, `SqliteAuthStoreMigrator.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs`
|
||||
|
||||
**Step 1: Failing tests:** migrator on a temp DB creates `api_keys`, `api_key_audit`, `schema_version`; idempotent (run twice = no error); refuses a newer on-disk schema. Use a temp-file DB per test, deleted on dispose.
|
||||
**Step 3:** WAL + busy_timeout; schema v1 = the three tables from mxaccessgw `SqliteAuthSchema.cs` (`api_keys`: key_id PK, key_prefix, secret_hash BLOB, display_name, scopes TEXT(json), constraints TEXT NULL, created_utc, last_used_utc NULL, revoked_utc NULL).
|
||||
**Step 5:** commit `feat(apikeys): sqlite schema + connection factory + migrator`.
|
||||
|
||||
### Task 11: SqliteApiKeyStore (read + mark-used)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3–7
|
||||
**Blocked by:** Task 10
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs`
|
||||
|
||||
Tests: `FindByKeyIdAsync` returns inserted record incl. revoked; `FindActiveByKeyIdAsync` filters revoked; `MarkUsedAsync` updates `last_used_utc` only for active keys. Implement `IApiKeyStore`. Commit `feat(apikeys): sqlite read store + mark-used`.
|
||||
|
||||
### Task 12: SqliteApiKeyAdminStore + audit
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3–7
|
||||
**Blocked by:** Task 11
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs`, `SqliteApiKeyAuditStore.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs`
|
||||
|
||||
Tests: create→find; revoke sets `revoked_utc`; rotate replaces hash + clears revoked/last-used; delete only when revoked; every op appends an audit row (`ListRecentAsync`). Implement `IApiKeyAdminStore` + append-only audit. Commit `feat(apikeys): admin store (create/revoke/rotate/delete) + audit`.
|
||||
|
||||
### Task 13: ApiKeyVerifier pipeline
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3–7
|
||||
**Blocked by:** Task 8, Task 9, Task 11
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs`
|
||||
|
||||
**Step 1: Failing tests** for the full pipeline + each discriminated failure: `MissingOrMalformed`, `KeyNotFound`, `KeyRevoked`, `PepperUnavailable`, `SecretMismatch`, and success → `ApiKeyIdentity` (with scopes + opaque constraints, **no secret**). Assert success marks the key used.
|
||||
**Step 3:** parse → `FindByKeyIdAsync` → reject revoked → hash with pepper → `FixedTimeEquals` → `MarkUsedAsync`. Port from mxaccessgw `ApiKeyVerifier.cs`. Opaque error to caller, discriminated reason for audit.
|
||||
**Step 5:** commit `feat(apikeys): verification pipeline with discriminated failures`.
|
||||
|
||||
### Task 14: Admin command set
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 3–7
|
||||
**Blocked by:** Task 12
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs` (+ parser/result types)
|
||||
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs`
|
||||
|
||||
Reusable verb handlers (`init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key`, `delete-key`) returning structured results (so each consumer wires its own CLI front-end). `create-key` prints the assembled token once. Port from mxaccessgw `ApiKeyAdminCliRunner.cs`. Commit `feat(apikeys): reusable admin command set`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — AspNetCore
|
||||
|
||||
### Task 15: Claim types, cookie defaults, DI helpers
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Blocked by:** Task 5, Task 13
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Auth.AspNetCore/ZbClaimTypes.cs`, `ZbCookieDefaults.cs`, `ServiceCollectionExtensions.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs`
|
||||
|
||||
**Step 1: Failing tests:** `AddZbLdapAuth(config)` binds `LdapOptions`, registers `ILdapAuthService` + validator (resolve from the built provider, assert non-null + options bound). `AddZbApiKeyAuth(config)` registers `IApiKeyVerifier` + stores + runs migrations flag. `ZbCookieDefaults` = HttpOnly, SameSite=Strict, configurable Secure, sliding idle. `ZbClaimTypes` defines name/display/username/role/scope-id constants.
|
||||
**Steps 3-5:** implement DI extensions; TDD; commit `feat(aspnetcore): claim types, cookie defaults, DI helpers`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Packaging
|
||||
|
||||
### Task 16: Pack metadata, produce nupkgs, publish script + README
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Blocked by:** Task 7, Task 14, Task 15
|
||||
|
||||
**Files:**
|
||||
- Modify: the 4 library `.csproj` (PackageId, Description, Authors, RepositoryUrl, `<IsPackable>true`)
|
||||
- Create: `build/pack.sh`, `build/push.sh`, `README.md`
|
||||
|
||||
**Steps:**
|
||||
1. Add package metadata to each lib csproj; ensure inter-package deps surface as NuGet deps (`.Ldap`→`.Abstractions`, etc.).
|
||||
2. `dotnet pack -c Release -o ./artifacts` → expect **4** `.nupkg` (Abstractions, Ldap, ApiKeys, AspNetCore) at version `0.1.0`.
|
||||
3. `build/push.sh`: `dotnet nuget push ./artifacts/*.nupkg --source <gitea-feed> --api-key $GITEA_NUGET_KEY` (creds from env; do not hardcode).
|
||||
4. `README.md`: package table, consumer matrix (OtOpcUa: Abstractions+Ldap+AspNetCore; gw & SB: all four; ApiKeys not OtOpcUa), versioning (lockstep), "library not service" note. Link the design docs in `scadaproj/components/auth/`.
|
||||
5. `dotnet test` (full suite green, integration skipped if no LDAP).
|
||||
6. **Commit:** `git commit -am "build: package metadata, pack/push scripts, README"`
|
||||
|
||||
**Acceptance:** `dotnet pack` emits 4 nupkgs; full unit suite green.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (separate follow-on plan)
|
||||
|
||||
Consumer **adoption** — migrating OtOpcUa / mxaccessgw / ScadaBridge onto these packages, the `canonical → native` role mappers per project, and config-key migration — is tracked in `scadaproj/components/auth/GAPS.md` (backlog #8) and warrants its own plan once `ZB.MOM.WW.Auth` `0.1.0` is published.
|
||||
|
||||
## Suggested execution order
|
||||
|
||||
`T1 → T2`, then the two chains in parallel: **Ldap** `T3,T4 → T5 → T6 → T7` and **ApiKeys** `T8,T9,T10 → T11 → T12 → T13`, `T14` after `T12`. Then `T15` (needs T5 + T13), then `T16` (needs T7 + T14 + T15).
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md",
|
||||
"repo": "~/Desktop/scadaproj/ZB.MOM.WW.Auth",
|
||||
"tasks": [
|
||||
{"id": 1, "nativeId": 6, "subject": "Task 1: Scaffold repo, solution, projects", "status": "pending", "blockedBy": []},
|
||||
{"id": 2, "nativeId": 7, "subject": "Task 2: Abstractions — all contract types", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 8, "subject": "Task 3: LDAP filter & DN escaping", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "nativeId": 9, "subject": "Task 4: ILdapConnection seam + Novell adapter + fake", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 5, "nativeId": 10, "subject": "Task 5: LdapAuthService happy path + validation", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 6, "nativeId": 11, "subject": "Task 6: LdapAuthService failure modes (fail-closed)", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeId": 12, "subject": "Task 7: GLAuth integration test (skippable)", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 13, "subject": "Task 8: API-key token parser + secret generator", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 9, "nativeId": 14, "subject": "Task 9: Peppered HMAC hasher + constant-time compare", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 10, "nativeId": 15, "subject": "Task 10: SQLite schema + connection factory + migrator", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 11, "nativeId": 16, "subject": "Task 11: SqliteApiKeyStore (read + mark-used)", "status": "pending", "blockedBy": [10]},
|
||||
{"id": 12, "nativeId": 17, "subject": "Task 12: SqliteApiKeyAdminStore + audit", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 13, "nativeId": 18, "subject": "Task 13: ApiKeyVerifier pipeline", "status": "pending", "blockedBy": [8, 9, 11]},
|
||||
{"id": 14, "nativeId": 19, "subject": "Task 14: Reusable admin command set", "status": "pending", "blockedBy": [12]},
|
||||
{"id": 15, "nativeId": 20, "subject": "Task 15: AspNetCore claim types, cookie defaults, DI", "status": "pending", "blockedBy": [5, 13]},
|
||||
{"id": 16, "nativeId": 21, "subject": "Task 16: Packaging — nupkgs, push script, README", "status": "pending", "blockedBy": [7, 14, 15]}
|
||||
],
|
||||
"lastUpdated": "2026-06-01"
|
||||
}
|
||||
Reference in New Issue
Block a user