commit 37e23cf9f21f25c307890c74400424c4da8ab68e Author: dohertj2 Date: Mon Jun 1 03:59:23 2026 -0400 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). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24d09b1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6ac9ed2 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/ZB.MOM.WW.Auth/.gitignore b/ZB.MOM.WW.Auth/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/ZB.MOM.WW.Auth/.gitignore @@ -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 diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props new file mode 100644 index 0000000..c4755a6 --- /dev/null +++ b/ZB.MOM.WW.Auth/Directory.Build.props @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + latest + 0.1.0 + true + + + diff --git a/ZB.MOM.WW.Auth/Directory.Packages.props b/ZB.MOM.WW.Auth/Directory.Packages.props new file mode 100644 index 0000000..907fce8 --- /dev/null +++ b/ZB.MOM.WW.Auth/Directory.Packages.props @@ -0,0 +1,35 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md new file mode 100644 index 0000000..a5c22d1 --- /dev/null +++ b/ZB.MOM.WW.Auth/README.md @@ -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 diff --git a/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx b/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx new file mode 100644 index 0000000..e5f051b --- /dev/null +++ b/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Auth/build/pack.sh b/ZB.MOM.WW.Auth/build/pack.sh new file mode 100755 index 0000000..471e865 --- /dev/null +++ b/ZB.MOM.WW.Auth/build/pack.sh @@ -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 diff --git a/ZB.MOM.WW.Auth/build/push.sh b/ZB.MOM.WW.Auth/build/push.sh new file mode 100755 index 0000000..c4a2a55 --- /dev/null +++ b/ZB.MOM.WW.Auth/build/push.sh @@ -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 diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs new file mode 100644 index 0000000..f0583ba --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs @@ -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 Scopes, object? Constraints); + +public sealed record ApiKeyVerification(bool Succeeded, ApiKeyIdentity? Identity, ApiKeyFailure? Failure); + +public interface IApiKeyVerifier +{ + Task VerifyAsync(string authorizationHeader, CancellationToken ct); +} + +/// +/// As a positional record, (byte[]) participates in equality +/// BY REFERENCE. Two records whose SecretHash arrays contain identical bytes are NOT +/// considered equal by . Callers must not rely on value +/// equality for ; use +/// or similar for content comparison. +/// +public sealed record ApiKeyRecord( + string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName, + IReadOnlySet Scopes, string? ConstraintsJson, + DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc); + +public interface IApiKeyStore +{ + Task FindByKeyIdAsync(string keyId, CancellationToken ct); + Task 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); + +/// +/// Hash-free projection of an API-key record, safe to enumerate and surface to admins. +/// Deliberately omits SecretHash so that listing keys can never leak secret material. +/// +public sealed record ApiKeyListItem( + string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet Scopes, + string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc); + +public interface IApiKeyAdminStore +{ + Task CreateAsync(ApiKeyRecord record, CancellationToken ct); + Task RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct); + Task RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct); + Task DeleteAsync(string keyId, CancellationToken ct); + + /// + /// Enumerates all API keys as hash-free projections, newest first. + /// The secret hash is never selected, so callers cannot use this to recover secret material. + /// + Task> ListAsync(CancellationToken ct); +} + +public interface IApiKeyAuditStore +{ + Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct); + Task> ListRecentAsync(int limit, CancellationToken ct); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs new file mode 100644 index 0000000..c442220 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapContracts.cs @@ -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 Groups, LdapAuthFailure? Failure) +{ + public static LdapAuthResult Success(string username, string displayName, IReadOnlyList groups) => new(true, username, displayName, groups, null); + public static LdapAuthResult Fail(LdapAuthFailure failure) => new(false, "", "", Array.Empty(), failure); +} + +public interface ILdapAuthService +{ + /// + /// Authenticates against the directory by bind-then-search and + /// returns the outcome, including the resolved display name and group memberships on success. + /// + /// The login name to authenticate. + /// The credential to bind with. + /// A token to request cancellation of the operation. + /// The authentication result. + /// + /// 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. + /// + Task AuthenticateAsync(string username, string password, CancellationToken ct); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Roles/RoleContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Roles/RoleContracts.cs new file mode 100644 index 0000000..5888893 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/Roles/RoleContracts.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.Auth.Abstractions.Roles; + +public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator } + +public sealed record GroupRoleMapping(IReadOnlyList Roles, object? Scope); + +/// +/// Maps a user's directory group memberships to a set of roles (typically +/// ) plus an opaque scope payload. +/// +/// The role vocabulary, e.g. . +/// +/// 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 scadaproj/components/auth/GAPS.md, gaps C1/C2). No default implementation is shipped here. +/// +public interface IGroupRoleMapper +{ + /// + /// Maps the supplied to the roles and scope they grant. + /// + /// The user's directory group memberships. + /// A token to request cancellation of the operation. + /// The roles granted and an opaque scope payload. + Task> MapAsync(IReadOnlyList groups, CancellationToken ct); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj new file mode 100644 index 0000000..909536d --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + true + ZB.MOM.WW.Auth.Abstractions + ZB.MOM.WW + Auth contracts and canonical roles for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + + + diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs new file mode 100644 index 0000000..cb24bfc --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs @@ -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; + +/// +/// Result of a verb that yields a freshly assembled token (create-key / rotate-key). +/// The is the ONLY moment the secret is ever available; it is never +/// retrievable afterwards. A null indicates the verb failed +/// (for example, rotating a key that does not exist). +/// +public sealed record CreateKeyResult(string KeyId, string? Token); + +/// Result of a mutating verb that succeeds or fails without yielding a token. +public sealed record KeyActionResult(bool Succeeded, string? Message); + +/// +/// 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 +/// via . +/// +/// +/// +/// create-key and rotate-key return the assembled token EXACTLY ONCE — the only +/// time the secret is ever available. No other result carries the secret or its hash; +/// is a hash-free projection by construction. +/// +/// +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; + + /// Creates the command set over the supplied stores and options. + /// API-key options (token prefix, store path, ...). + /// Mutating store (create / revoke / rotate / delete / list). + /// Append-only audit store wired into every mutating verb. + /// Resolves the pepper used to hash secrets. + /// Schema migrator used by . + /// Optional clock; defaults to . + 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; + } + + /// + /// init-db: applies the schema migration, then appends an init-db audit entry. + /// + 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); + } + + /// + /// create-key: generates a secret, persists its hash, appends a create-key audit entry, + /// and returns the assembled token <prefix>_<keyId>_<secret> EXACTLY ONCE. + /// + /// The pepper is unavailable; nothing is persisted or audited. + public async Task CreateKeyAsync( + string keyId, + string displayName, + IReadOnlySet 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)); + } + + /// + /// list-keys: returns the hash-free projection of every key, + /// newest first. This is a read, so it appends no audit entry and never carries secret material. + /// + public Task> ListKeysAsync(CancellationToken ct) => + _adminStore.ListAsync(ct); + + /// + /// revoke-key: marks the key revoked and appends a revoke-key audit entry. + /// All attempts are audited, including failures (key not found or already revoked) — this is + /// intentional to maintain a complete security trail. + /// + public async Task 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); + } + + /// + /// rotate-key: replaces the stored secret with a freshly generated one and appends a + /// rotate-key audit entry. Returns a whose token is the new + /// secret (shown once); a null 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. + /// + /// The pepper is unavailable; nothing is persisted or audited. + public async Task 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); + } + + /// + /// delete-key: removes the key (only succeeds once it has been revoked) and appends a + /// delete-key audit entry. + /// All attempts are audited, including failures (key not found or not yet revoked) — this is + /// intentional to maintain a complete security trail. + /// + public async Task 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); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyParser.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyParser.cs new file mode 100644 index 0000000..729782e --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyParser.cs @@ -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 "; + + /// + /// Attempts to parse an Authorization header value or a raw token into a . + /// Accepts an optional case-insensitive "Bearer " scheme prefix before the token. + /// Token format: <tokenPrefix>_<keyId>_<secret>. + /// The secret may itself contain underscores; only the first underscore after the key-id is used as + /// the key-id/secret separator. + /// + /// Authorization header value or raw token string. + /// Expected token prefix (e.g. "mxgw"), without trailing underscore. + /// A on success, or null if the input is malformed. + 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 "_" + string requiredPrefix = tokenPrefix + "_"; + if (!token.StartsWith(requiredPrefix, StringComparison.OrdinalIgnoreCase)) + return null; + + // Everything after "_" is "_" + 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); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretGenerator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretGenerator.cs new file mode 100644 index 0000000..225ed51 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretGenerator.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; + +namespace ZB.MOM.WW.Auth.ApiKeys; + +internal static class ApiKeySecretGenerator +{ + /// + /// Generates a new cryptographically secure API key secret. + /// Returns 32 random bytes encoded as URL-safe base64 (no padding, no '+', no '/'). + /// + public static string NewSecret() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretHasher.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretHasher.cs new file mode 100644 index 0000000..2a7b269 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretHasher.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ZB.MOM.WW.Auth.ApiKeys; + +internal static class ApiKeySecretHasher +{ + /// + /// Computes HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)). + /// + 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); + } + + /// + /// Returns true iff HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)) equals + /// , using a constant-time comparison. + /// Returns false (without throwing) if the lengths differ. + /// + public static bool Verify(string secret, string pepper, byte[] expectedHash) + { + byte[] actualHash = Hash(secret, pepper); + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs new file mode 100644 index 0000000..e0cb1d9 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs @@ -0,0 +1,75 @@ +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; + +namespace ZB.MOM.WW.Auth.ApiKeys; + +/// +/// 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. +/// +/// +/// 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 ConstraintsJson blob (which the verifier does not interpret); it never carries the +/// presented secret, the pepper, or the stored secret hash. +/// +public sealed class ApiKeyVerifier( + ApiKeyOptions options, + IApiKeyStore store, + IApiKeyPepperProvider pepperProvider, + TimeProvider? timeProvider = null) : IApiKeyVerifier +{ + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + + /// + public async Task 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); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs new file mode 100644 index 0000000..a99dd5e --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs @@ -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; + +/// +/// Runs the API-key SQLite schema migration at application startup when +/// is . +/// The migration is idempotent, so repeated restarts are safe. +/// +internal sealed class ApiKeyMigrationHostedService( + SqliteAuthStoreMigrator migrator, + IOptions options) : IHostedService +{ + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (options.Value.RunMigrationsOnStartup) + { + await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs new file mode 100644 index 0000000..bd7a62c --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs @@ -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; + +/// +/// 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. +/// +public static class ApiKeyServiceCollectionExtensions +{ + /// + /// Registers API-key authentication: binds from the + /// configuration section at , wires up the SQLite-backed + /// stores and the configuration-backed pepper provider, and registers + /// . + /// + /// The service collection to add to. + /// The application configuration. + /// Path of the configuration section holding the API-key options. + /// The same instance, for chaining. + public static IServiceCollection AddZbApiKeyAuth( + this IServiceCollection services, + IConfiguration config, + string sectionPath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); + + services.Configure(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(); + + // 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>().Value.SqlitePath)); + + services.TryAddSingleton(sp => + new SqliteApiKeyStore(sp.GetRequiredService())); + services.TryAddSingleton(sp => + new SqliteApiKeyAdminStore(sp.GetRequiredService())); + services.TryAddSingleton(sp => + new SqliteApiKeyAuditStore(sp.GetRequiredService())); + + services.TryAddSingleton(sp => + new ApiKeyVerifier( + sp.GetRequiredService>().Value, + sp.GetRequiredService(), + sp.GetRequiredService())); + + // 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())); + + // Hosted service that runs migrations on startup when ApiKeyOptions.RunMigrationsOnStartup. + services.AddHostedService(); + + return services; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ConfigurationApiKeyPepperProvider.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ConfigurationApiKeyPepperProvider.cs new file mode 100644 index 0000000..c5b9fe9 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ConfigurationApiKeyPepperProvider.cs @@ -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; + +/// +/// Configuration-backed that resolves the API-key pepper +/// from using the key name in +/// . +/// +/// +/// 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, +/// returns /empty, which the verifier treats as a +/// fail-closed "pepper unavailable" condition. +/// +public sealed class ConfigurationApiKeyPepperProvider( + IConfiguration config, + IOptions options) : IApiKeyPepperProvider +{ + /// + public string? GetPepper() + { + string secretName = options.Value.PepperSecretName; + + return string.IsNullOrWhiteSpace(secretName) + ? null + : config[secretName]; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/IApiKeyPepperProvider.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/IApiKeyPepperProvider.cs new file mode 100644 index 0000000..71db408 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/IApiKeyPepperProvider.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.Auth.ApiKeys; + +/// +/// Resolves the secret pepper used to verify API-key secret hashes. +/// +/// +/// 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. +/// +public interface IApiKeyPepperProvider +{ + /// + /// Returns the resolved pepper, or null/empty if it is currently unavailable. + /// + /// The pepper value, or null/whitespace when unavailable. + string? GetPepper(); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/AuthSqliteConnectionFactory.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/AuthSqliteConnectionFactory.cs new file mode 100644 index 0000000..c62d343 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/AuthSqliteConnectionFactory.cs @@ -0,0 +1,87 @@ +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// +/// Factory for creating and opening SQLite connections to the API-key store. +/// +public sealed class AuthSqliteConnectionFactory +{ + /// + /// Busy timeout applied to every connection. SQLite retries a busy database for + /// this long before surfacing SQLITE_BUSY, so the concurrent + /// mark-used / audit-append writers degrade gracefully under load instead of + /// failing the request path. + /// + private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5); + + private readonly string _sqlitePath; + + /// Creates a factory targeting the database at . + /// Filesystem path of the SQLite database file. + public AuthSqliteConnectionFactory(string sqlitePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath); + _sqlitePath = sqlitePath; + } + + /// + /// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer + /// , which also applies WAL journaling and the + /// busy timeout. + /// + 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()); + } + + /// + /// 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 SQLITE_BUSY as a hard failure. + /// + /// Cancellation token for the operation. + /// An opened and configured SQLite connection. + public async Task 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); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs new file mode 100644 index 0000000..801842d --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs @@ -0,0 +1,35 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// +/// Serializes API-key scope sets to a canonical JSON array. Scopes are sorted with +/// so that equal sets always produce identical +/// column text, regardless of insertion order. +/// +public static class ScopeSerializer +{ + /// Serializes scopes to an ordinal-sorted JSON array. + /// The scopes to serialize. + /// A JSON array string with elements sorted ordinally. + public static string Serialize(IReadOnlySet scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal)); + } + + /// Deserializes scopes from a JSON array string. + /// The JSON string to deserialize; may be null or empty. + /// An ordinal-compared set of scopes; empty when the input is null/blank. + public static IReadOnlySet Deserialize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new HashSet(StringComparer.Ordinal); + } + + string[]? scopes = JsonSerializer.Deserialize(value); + + return new HashSet(scopes ?? [], StringComparer.Ordinal); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs new file mode 100644 index 0000000..9607c23 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs @@ -0,0 +1,143 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; + +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// +/// SQLite-backed administration store for API keys (create, revoke, rotate, delete). +/// +public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore +{ + /// + 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); + } + + /// + public async Task 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; + } + + /// + public async Task 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; + } + + /// + public async Task 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; + } + + /// + public async Task> 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 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; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAuditStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAuditStore.cs new file mode 100644 index 0000000..eb871d7 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAuditStore.cs @@ -0,0 +1,67 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; + +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// SQLite-backed, append-only audit store for API-key events. +public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore +{ + /// + 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); + } + + /// + public async Task> 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 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; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs new file mode 100644 index 0000000..8e2bff5 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs @@ -0,0 +1,76 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; + +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// SQLite-backed read store for API-key records. +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"; + + /// + public Task FindByKeyIdAsync(string keyId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + return FindAsync(keyId, requireActive: false, ct); + } + + /// + public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + return FindAsync(keyId, requireActive: true, ct); + } + + /// + 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 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(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)); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthSchema.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthSchema.cs new file mode 100644 index 0000000..f3afd55 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthSchema.cs @@ -0,0 +1,64 @@ +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// +/// Schema constants and table DDL for the API-key SQLite store. +/// +public static class SqliteAuthSchema +{ + /// The schema version this build creates and supports. + public const int CurrentVersion = 1; + + /// Name of the single-row table tracking the applied schema version. + public const string SchemaVersionTable = "schema_version"; + + /// Name of the table storing API-key records. + public const string ApiKeysTable = "api_keys"; + + /// Name of the append-only audit table. + public const string ApiKeyAuditTable = "api_key_audit"; + + /// DDL creating the single-row schema-version table. + 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 + ); + """; + + /// DDL creating the API-key record table. + 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 + ); + """; + + /// DDL creating the append-only audit table. + 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 + ); + """; + + /// DDL creating supporting indexes (idempotent). + 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); + """; +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs new file mode 100644 index 0000000..416cad8 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// Thrown when the auth store cannot be migrated to the supported schema. +public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message); + +/// +/// 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. +/// +public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) +{ + /// Applies the schema migration to the auth store. + /// Cancellation token. + /// + /// The on-disk schema version is newer than . + /// + 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 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); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteValueParsing.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteValueParsing.cs new file mode 100644 index 0000000..e58df33 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteValueParsing.cs @@ -0,0 +1,20 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +/// +/// 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. +/// +internal static class SqliteValueParsing +{ + /// Parses a round-trip ("O") formatted timestamp written by the stores. + internal static DateTimeOffset ParseUtc(string value) => + DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + + /// Reads a nullable round-trip timestamp at . + internal static DateTimeOffset? ReadNullableUtc(SqliteDataReader reader, int ordinal) => + reader.IsDBNull(ordinal) ? null : ParseUtc(reader.GetString(ordinal)); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj new file mode 100644 index 0000000..72aeab0 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + + true + ZB.MOM.WW.Auth.ApiKeys + ZB.MOM.WW + SQLite-backed API-key store with pepper-based hashing for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + + + + + + + diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..3cabb80 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs @@ -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; + +/// +/// Dependency-injection helpers that wire up the ZB.MOM.WW LDAP authentication provider +/// from configuration. Composes the concrete implementation living in the +/// ZB.MOM.WW.Auth.Ldap package so consuming apps register a provider with a single call. +/// +/// +/// API-key DI wiring lives in ZB.MOM.WW.Auth.ApiKeys +/// (ZB.MOM.WW.Auth.ApiKeys.DependencyInjection.ApiKeyServiceCollectionExtensions.AddZbApiKeyAuth) +/// so that an LDAP-only consumer can reference this package without pulling in SQLite. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers LDAP authentication: binds and validates from the + /// configuration section at , and registers + /// . + /// + /// The service collection to add to. + /// The application configuration. + /// Path of the configuration section holding the LDAP options. + /// The same instance, for chaining. + public static IServiceCollection AddZbLdapAuth( + this IServiceCollection services, + IConfiguration config, + string sectionPath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); + + services.Configure(config.GetSection(sectionPath)); + + // Fail fast at startup on a misconfigured directory rather than on first login. + services.AddSingleton, 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(sp => + new LdapAuthService(sp.GetRequiredService>().Value)); + + return services; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj new file mode 100644 index 0000000..5b7e18d --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + + + + true + ZB.MOM.WW.Auth.AspNetCore + ZB.MOM.WW + ASP.NET Core DI helpers, cookie defaults, and claim mappings for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZbClaimTypes.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZbClaimTypes.cs new file mode 100644 index 0000000..1977236 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZbClaimTypes.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; + +namespace ZB.MOM.WW.Auth.AspNetCore; + +/// +/// 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. +/// +/// +/// and deliberately alias the framework's +/// and URIs so that ASP.NET +/// Core's built-in name resolution and +/// [Authorize(Roles = ...)] / checks +/// work without bespoke configuration. The remaining claim types are app-specific and use +/// stable, short zb:-prefixed names that will not collide with the framework URIs. +/// +public static class ZbClaimTypes +{ + /// + /// The principal's name claim. Aliases so the framework + /// populates from it. + /// + public const string Name = ClaimTypes.Name; + + /// + /// A role claim. Aliases so [Authorize(Roles = ...)] + /// and resolve against it by default. + /// + public const string Role = ClaimTypes.Role; + + /// Human-friendly display name (distinct from the login ). + public const string DisplayName = "zb:displayname"; + + /// The directory/login username the principal authenticated as. + public const string Username = "zb:username"; + + /// The identifier of the scope (site/area) the principal's roles apply within. + public const string ScopeId = "zb:scopeid"; +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZbCookieDefaults.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZbCookieDefaults.cs new file mode 100644 index 0000000..661472c --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ZbCookieDefaults.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; + +namespace ZB.MOM.WW.Auth.AspNetCore; + +/// +/// Applies the hardened cookie-authentication defaults shared by ZB.MOM.WW apps: +/// HTTP-only, , sliding expiration, a caller-supplied idle +/// timeout, and a configurable HTTPS requirement. +/// +/// +/// The cookie name 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. +/// +public static class ZbCookieDefaults +{ + /// + /// 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. + /// + public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(30); + + /// + /// Applies the hardened defaults to . + /// + /// The cookie-authentication options to mutate. + /// + /// When (the default), the cookie is only ever sent over HTTPS + /// (). Set to only for local + /// development over plain HTTP (: Secure is + /// still set when the current request is HTTPS, which is safer than None). + /// + /// + /// The sliding idle timeout. Defaults to when not specified. + /// + /// is . + 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; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs new file mode 100644 index 0000000..79a8e88 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.Auth.Ldap.Internal; + +using ZB.MOM.WW.Auth.Abstractions.Ldap; + +/// +/// A single LDAP search result entry: its DN and a flat attribute bag. +/// +internal sealed record LdapSearchEntry( + string Dn, + IReadOnlyDictionary> Attributes); + +/// +/// Abstraction over a single LDAP connection. Allows unit-testing +/// LdapAuthService without a live directory server. +/// +internal interface ILdapConnection : IDisposable +{ + /// Opens (and optionally upgrades to TLS) a connection to the given host. + void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs); + + /// Binds with the supplied DN and password. Throws LdapException on bad credentials. + void Bind(string dn, string password); + + /// Executes a subtree search and returns all matching entries. + IReadOnlyList Search(string searchBase, string filter, IReadOnlyList attributes); +} + +/// Factory that produces instances. +internal interface ILdapConnectionFactory +{ + ILdapConnection Create(); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEscaping.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEscaping.cs new file mode 100644 index 0000000..4aa8810 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEscaping.cs @@ -0,0 +1,121 @@ +namespace ZB.MOM.WW.Auth.Ldap.Internal; + +/// +/// RFC 4515 LDAP filter escaping and RFC 4514 DN attribute-value escaping utilities. +/// +internal static class LdapEscaping +{ + /// + /// 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. + /// + 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"); + } + + /// + /// 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. + /// + 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(); + } + + /// + /// Extracts the value of the first RDN from a DN, e.g. + /// cn=Engineers,ou=g,dc=xEngineers. The scan is RFC 4514 escape-aware: + /// a backslash-escaped , inside the RDN value does not terminate it, and recognised + /// escape sequences — single-character (\, \\ …) and two-digit hex + /// (\2c) — are unescaped, so a group CN that legitimately contains a comma is + /// returned intact (Security-013). A string with no = is returned unchanged. + /// + 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'); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs new file mode 100644 index 0000000..c7bc1c6 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs @@ -0,0 +1,105 @@ +namespace ZB.MOM.WW.Auth.Ldap.Internal; + +using Novell.Directory.Ldap; +using ZB.MOM.WW.Auth.Abstractions.Ldap; + +/// +/// Production backed by Novell.Directory.Ldap.LdapConnection. +/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService. +/// +internal sealed class NovellLdapConnection : ILdapConnection +{ + private readonly LdapConnection _conn = new(); + private bool _disposed; + + /// + 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."); + } + } + + /// + public void Bind(string dn, string password) + => _conn.Bind(dn, password); + + /// + public IReadOnlyList Search( + string searchBase, + string filter, + IReadOnlyList attributes) + { + var results = _conn.Search( + searchBase, + LdapConnection.ScopeSub, + filter, + attributes.ToArray(), + typesOnly: false); + + var entries = new List(); + while (results.HasMore()) + { + var entry = results.Next(); + var attrs = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (LdapAttribute attr in entry.GetAttributeSet()) + { + attrs[attr.Name] = attr.StringValueArray.ToList(); + } + + entries.Add(new LdapSearchEntry(entry.Dn, attrs)); + } + + return entries; + } + + /// + 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; + } +} + +/// Factory that produces fresh instances. +internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory +{ + public ILdapConnection Create() => new NovellLdapConnection(); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs new file mode 100644 index 0000000..53ca248 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs @@ -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; + +/// +/// 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 +/// so the logic is unit-testable without a live server. +/// +/// +/// Fully fail-closed: authentication never throws to the caller — every error, expected +/// or unexpected, is mapped to a structured . +/// 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 () are +/// kept distinct from end-user bind failures () so +/// a system misconfiguration is not mistaken for bad user input. +/// +public sealed class LdapAuthService : ILdapAuthService +{ + private readonly LdapOptions _options; + private readonly ILdapConnectionFactory _connectionFactory; + + /// + /// Production constructor: binds against a live directory via the real + /// Novell-backed connection factory. + /// + public LdapAuthService(LdapOptions options) + : this(options, new NovellLdapConnectionFactory()) + { + } + + /// + /// Test/seam constructor: accepts an injected + /// so the bind/search logic can be exercised without a live directory. Internal + /// because the connection seam is an implementation detail. + /// + internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory) + { + _options = options; + _connectionFactory = connectionFactory; + } + + /// + /// + /// 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 . A + /// Succeeded == true result is only ever returned when the user resolved to exactly + /// one entry, their password verified, AND at least one group was extracted. + /// + public Task 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 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 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); + } + } + + /// + /// Internal control-flow exception that carries an already-mapped + /// out of a stage to the single fail-closed catch site. Never escapes this type. + /// + private sealed class StageFailure : Exception + { + public StageFailure(LdapAuthFailure failure) => Failure = failure; + + public LdapAuthFailure Failure { get; } + } + + /// + /// 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. + /// + private IReadOnlyList BuildSearchAttributes() + { + if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase)) + return new[] { _options.DisplayNameAttribute }; + + return new[] { _options.DisplayNameAttribute, _options.GroupAttribute }; + } + + /// + /// 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. + /// + private string ExtractDisplayName(LdapSearchEntry entry, string username) + { + if (entry.Attributes.TryGetValue(_options.DisplayNameAttribute, out var values) && values.Count > 0) + return values[0]; + + return username; + } + + /// + /// Extracts group short names from the configured group attribute. Each value is a + /// group DN (e.g. cn=Engineers,ou=g,dc=x); the first RDN's value is returned + /// (e.g. Engineers), RFC 4514 escape-aware so an escaped comma in the CN is + /// preserved rather than truncating the name. + /// + private IReadOnlyList ExtractGroups(LdapSearchEntry entry) + { + if (!entry.Attributes.TryGetValue(_options.GroupAttribute, out var values) || values.Count == 0) + return Array.Empty(); + + var groups = new List(values.Count); + foreach (var value in values) + groups.Add(ToGroupShortName(value)); + + return groups; + } + + /// + /// Yields a group's short name from its DN by returning the value of the first RDN + /// (e.g. cn=Engineers,ou=g,dc=xEngineers). The extraction is RFC 4514 + /// escape-aware (), so a CN that legitimately + /// contains an escaped comma — cn=Eng\,ineers,... or cn=A\2cB,... — is + /// returned intact rather than truncated at the escaped comma. Values with no = + /// are returned unchanged. + /// + private static string ToGroupShortName(string groupDn) + => LdapEscaping.FirstRdnValue(groupDn); +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs new file mode 100644 index 0000000..68e0b2b --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.Ldap; + +namespace ZB.MOM.WW.Auth.Ldap; + +/// +/// Validates 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. +/// +/// +/// Four conditions are enforced: +/// +/// plaintext transport () is rejected unless +/// is explicitly set (dev/test only); +/// must be specified (no sane default host); +/// must be specified (the DN root every +/// search runs against); +/// must be specified — an empty value +/// would bind anonymously, defeating the search-then-bind authentication flow. +/// +/// +public sealed class LdapOptionsValidator : IValidateOptions +{ + /// + 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; + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj new file mode 100644 index 0000000..4c58f23 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + + + + true + ZB.MOM.WW.Auth.Ldap + ZB.MOM.WW + LDAP authentication service (GLAuth / Active Directory) for the ZB.MOM.WW SCADA family. + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs new file mode 100644 index 0000000..f7aaf44 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs @@ -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 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(["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 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(() => commands.CreateKeyAsync( + "key-x", + "No Pepper", + new HashSet(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(() => commands.CreateKeyAsync( + "a_b", + "Service A", + new HashSet(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(["read"], StringComparer.Ordinal), + constraintsJson: null, + remoteAddress: null, + CancellationToken.None); + + IReadOnlyList 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(["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 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(["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 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 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(["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(() => + 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(["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 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 = "__"; secret may contain underscores. + Assert.NotNull(token); + string[] parts = token!.Split('_', 3); + return parts[2]; + } + + private async Task 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. + } + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyParserTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyParserTests.cs new file mode 100644 index 0000000..9533df0 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyParserTests.cs @@ -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); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretGeneratorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretGeneratorTests.cs new file mode 100644 index 0000000..5a09a77 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretGeneratorTests.cs @@ -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()); + } + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretHasherTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretHasherTests.cs new file mode 100644 index 0000000..62b1b0f --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretHasherTests.cs @@ -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)); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..38bf685 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs @@ -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 + { + [$"{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(); + + 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()); + Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); + } + + [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>(); + + 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(); + + Assert.IsType(pepperProvider); + Assert.Equal(PepperValue, pepperProvider.GetPepper()); + } + + [Fact] + public void ConfigurationApiKeyPepperProvider_ReturnsNull_WhenSecretNameUnset() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .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()) + .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().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().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); + } + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs new file mode 100644 index 0000000..242d618 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs @@ -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 Scopes = + new HashSet { "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( + () => 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 FindByKeyIdAsync(string keyId, CancellationToken ct) + { + FindByKeyIdCalled = true; + return Task.FromResult(Record); + } + + public Task 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; + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs new file mode 100644 index 0000000..da4af84 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs @@ -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( + () => _admin.RevokeAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Rotate_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) + { + await Assert.ThrowsAnyAsync( + () => _admin.RotateAsync(keyId!, [1, 2, 3], CancellationToken.None)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Delete_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) + { + await Assert.ThrowsAnyAsync( + () => _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 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 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 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 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(["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. + } + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs new file mode 100644 index 0000000..bf98278 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs @@ -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( + () => _store.FindByKeyIdAsync(keyId!, CancellationToken.None)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task FindActiveByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) + { + await Assert.ThrowsAnyAsync( + () => _store.FindActiveByKeyIdAsync(keyId!, CancellationToken.None)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task MarkUsed_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) + { + await Assert.ThrowsAnyAsync( + () => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None)); + } + + [Fact] + public void ScopeSerializer_RoundTripsAndSortsOrdinally() + { + var unsorted = new HashSet(["zeta", "alpha", "mike"], StringComparer.Ordinal); + var differentOrder = new HashSet(["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 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(["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. + } + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs new file mode 100644 index 0000000..c5e08c8 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs @@ -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( + () => migrator.MigrateAsync(CancellationToken.None)); + } + + private async Task 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 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 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. + } + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ZB.MOM.WW.Auth.ApiKeys.Tests.csproj b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ZB.MOM.WW.Auth.ApiKeys.Tests.csproj new file mode 100644 index 0000000..6219460 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ZB.MOM.WW.Auth.ApiKeys.Tests.csproj @@ -0,0 +1,29 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..ea32612 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs @@ -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 + { + [$"{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(); + + 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(); + var second = provider.GetRequiredService(); + + 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>(); + + 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>().ToList(); + + Assert.Contains(validators, v => v is LdapOptionsValidator); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZB.MOM.WW.Auth.AspNetCore.Tests.csproj b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZB.MOM.WW.Auth.AspNetCore.Tests.csproj new file mode 100644 index 0000000..3850254 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZB.MOM.WW.Auth.AspNetCore.Tests.csproj @@ -0,0 +1,31 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZbClaimTypesTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZbClaimTypesTests.cs new file mode 100644 index 0000000..4a549c0 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZbClaimTypesTests.cs @@ -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); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZbCookieDefaultsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZbCookieDefaultsTests.cs new file mode 100644 index 0000000..afa8205 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ZbCookieDefaultsTests.cs @@ -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(() => ZbCookieDefaults.Apply(null!)); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs new file mode 100644 index 0000000..ae9fd39 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs @@ -0,0 +1,233 @@ +using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.Auth.Ldap.Internal; + +/// +/// Test double for . Script results and error +/// conditions with the builder methods; inspect recorded calls via properties. +/// Consumed by Task 5 (LdapAuthService) unit tests. +/// +internal sealed class FakeLdapConnection : ILdapConnection +{ + // ---- scripted state ----- + + private readonly List _scriptedEntries = new(); + private readonly HashSet _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 BoundDns { get; } = new(); + + /// + /// Count of attempts (including ones that throw). The first attempt is + /// the service-account bind; the second is the user bind. Used to distinguish the two. + /// + public int BindAttempts { get; private set; } + + // ---- builder methods ----- + + /// + /// Scripts a user entry that will be returned by the next call. + /// Builds a minimal attribute bag with memberOf and optional displayName. + /// + public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null) + { + var attrs = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["memberOf"] = memberOf.ToList() + }; + if (displayName is not null) + attrs["displayName"] = new[] { displayName }; + + _scriptedEntries.Add(new LdapSearchEntry(dn, attrs)); + return this; + } + + /// + /// Configures the fake to throw when + /// is called for (simulates bad credentials). + /// + public FakeLdapConnection ThrowOnBind(string dn) + { + _throwBindDns.Add(dn); + return this; + } + + /// + /// Throw 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. + /// + public FakeLdapConnection ThrowOnUserBind() + { + _throwOnUserBind = true; + return this; + } + + /// + /// Throw on the FIRST bind — the + /// service-account bind — to simulate a service-account misconfiguration. Distinct from + /// ; this fails before the directory search ever runs. + /// + public FakeLdapConnection ThrowOnServiceBind() + { + _throwOnServiceBind = true; + return this; + } + + /// + /// Throw from to + /// simulate an unreachable directory (infrastructure failure). + /// + public FakeLdapConnection ThrowOnConnect() + { + _throwOnConnect = true; + return this; + } + + /// + /// Scripts a search that returns ZERO entries (no call also + /// yields zero, but this states the intent explicitly). Simulates user-not-found. + /// + public FakeLdapConnection WithNoMatch() => this; + + /// + /// 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. + /// + 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 Search( + string searchBase, + string filter, + IReadOnlyList attributes) + => _scriptedEntries.AsReadOnly(); + + public void Dispose() { /* nothing to clean up */ } +} + +/// Factory that always returns the same pre-configured fake instance. +internal sealed class FakeLdapConnectionFactory : ILdapConnectionFactory +{ + /// Wraps a caller-supplied fake so a test can script it before handing it to the service. + public FakeLdapConnectionFactory(FakeLdapConnection fake) => Fake = fake; + + /// Convenience overload that creates a bare, unscripted fake. + 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( + () => 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( + () => fake.Bind("cn=svc,dc=example,dc=com", "secret")); + } + + [Fact] + public void ThrowOnConnect_ThrowsLdapException() + { + var fake = new FakeLdapConnection().ThrowOnConnect(); + + Assert.Throws( + () => 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); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs new file mode 100644 index 0000000..fe94dee --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs @@ -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 +{ + /// + /// 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 ZB_LDAP_IT=1 is set; skipped again if the server is unreachable. + /// + [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); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailureTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailureTests.cs new file mode 100644 index 0000000..42eadf8 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailureTests.cs @@ -0,0 +1,156 @@ +using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.Auth.Ldap; + +namespace ZB.MOM.WW.Auth.Ldap.Tests; + +/// +/// Task 6 failure-mode tests. These pin the fail-closed contract: every error path returns a +/// structured , the method never throws, and +/// a successful result always carries at least one group. +/// +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()); + + 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( + () => 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); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs new file mode 100644 index 0000000..f9f66d3 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs @@ -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); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEscapingTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEscapingTests.cs new file mode 100644 index 0000000..a99d7e7 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEscapingTests.cs @@ -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("ab", @"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)); +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs new file mode 100644 index 0000000..9357f08 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapOptionsValidatorTests.cs @@ -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); +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/ZB.MOM.WW.Auth.Ldap.Tests.csproj b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/ZB.MOM.WW.Auth.Ldap.Tests.csproj new file mode 100644 index 0000000..3c88607 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/ZB.MOM.WW.Auth.Ldap.Tests.csproj @@ -0,0 +1,24 @@ + + + + false + + + + + + + + + + + + + + + + + + + + diff --git a/components/README.md b/components/README.md new file mode 100644 index 0000000..a65fa6b --- /dev/null +++ b/components/README.md @@ -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: + +``` +/ + 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 + .md # proposed shared-library API: packages, interfaces, options, records + current-state/ + / # 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//`** 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//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//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. diff --git a/components/auth/GAPS.md b/components/auth/GAPS.md new file mode 100644 index 0000000..0cae63a --- /dev/null +++ b/components/auth/GAPS.md @@ -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` 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 `__` | — | ✅ | ⛔ 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` 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). diff --git a/components/auth/README.md b/components/auth/README.md new file mode 100644 index 0000000..f458206 --- /dev/null +++ b/components/auth/README.md @@ -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//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. diff --git a/components/auth/current-state/mxaccessgw/CURRENT-STATE.md b/components/auth/current-state/mxaccessgw/CURRENT-STATE.md new file mode 100644 index 0000000..df8b328 --- /dev/null +++ b/components/auth/current-state/mxaccessgw/CURRENT-STATE.md @@ -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__`. 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. diff --git a/components/auth/current-state/otopcua/CURRENT-STATE.md b/components/auth/current-state/otopcua/CURRENT-STATE.md new file mode 100644 index 0000000..7c4a124 --- /dev/null +++ b/components/auth/current-state/otopcua/CURRENT-STATE.md @@ -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`; 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. diff --git a/components/auth/current-state/scadabridge/CURRENT-STATE.md b/components/auth/current-state/scadabridge/CURRENT-STATE.md new file mode 100644 index 0000000..f5ce4ea --- /dev/null +++ b/components/auth/current-state/scadabridge/CURRENT-STATE.md @@ -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` 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). diff --git a/components/auth/shared-contract/ZB.MOM.WW.Auth.md b/components/auth/shared-contract/ZB.MOM.WW.Auth.md new file mode 100644 index 0000000..89a2da4 --- /dev/null +++ b/components/auth/shared-contract/ZB.MOM.WW.Auth.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 Groups, LdapAuthFailure? Failure); +public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled } + +public interface ILdapAuthService { // §2 + Task AuthenticateAsync(string username, string password, CancellationToken ct); +} + +public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator } // ../spec/CANONICAL-ROLES.md + +public interface IGroupRoleMapper { // §3 — TRole defaults to CanonicalRole; backing store stays per-project + Task> MapAsync(IReadOnlyList groups, CancellationToken ct); +} +public sealed record GroupRoleMapping(IReadOnlyList 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 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 Scopes, object? Constraints); + +public interface IApiKeyStore { // default: SQLite (hash, scopes, constraints, audit) + Task 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. diff --git a/components/auth/spec/CANONICAL-ROLES.md b/components/auth/spec/CANONICAL-ROLES.md new file mode 100644 index 0000000..8cc3d3e --- /dev/null +++ b/components/auth/spec/CANONICAL-ROLES.md @@ -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` +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. diff --git a/components/auth/spec/SPEC.md b/components/auth/spec/SPEC.md new file mode 100644 index 0000000..0cdb282 --- /dev/null +++ b/components/auth/spec/SPEC.md @@ -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`; 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 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 `.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. diff --git a/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md b/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md new file mode 100644 index 0000000..636530e --- /dev/null +++ b/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md @@ -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/` for the 4 libs; `dotnet new xunit -f net10.0 -o tests/.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`: `net10.0`, `enable`, `enable`, `latest`, `0.1.0` (lockstep), `true`. +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`, `GroupRoleMapping`) +- 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> 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, `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 --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). diff --git a/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md.tasks.json b/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md.tasks.json new file mode 100644 index 0000000..6395969 --- /dev/null +++ b/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md.tasks.json @@ -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" +}