up
Some checks failed
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
root
2025-10-10 06:53:40 +00:00
parent 3aed135fb5
commit df5984d07e
1081 changed files with 97764 additions and 61389 deletions

View File

@@ -41,6 +41,7 @@ jobs:
environment: ${{ github.event_name == 'pull_request' && 'preview' || 'staging' }}
env:
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/webservice
AUTHORITY_PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/authority
TEST_RESULTS_DIR: ${{ github.workspace }}/artifacts/test-results
steps:
- name: Checkout repository
@@ -85,6 +86,36 @@ jobs:
if-no-files-found: error
retention-days: 7
- name: Restore Authority solution
run: dotnet restore src/StellaOps.Authority/StellaOps.Authority.sln
- name: Build Authority solution
run: dotnet build src/StellaOps.Authority/StellaOps.Authority.sln --configuration $BUILD_CONFIGURATION --no-restore -warnaserror
- name: Run Authority tests
run: |
dotnet test src/StellaOps.Configuration.Tests/StellaOps.Configuration.Tests.csproj \
--configuration $BUILD_CONFIGURATION \
--no-build \
--logger "trx;LogFileName=stellaops-authority-tests.trx" \
--results-directory "$TEST_RESULTS_DIR"
- name: Publish Authority web service
run: |
mkdir -p "$AUTHORITY_PUBLISH_DIR"
dotnet publish src/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj \
--configuration $BUILD_CONFIGURATION \
--no-build \
--output "$AUTHORITY_PUBLISH_DIR"
- name: Upload Authority artifacts
uses: actions/upload-artifact@v4
with:
name: authority-publish
path: ${{ env.AUTHORITY_PUBLISH_DIR }}
if-no-files-found: error
retention-days: 7
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
@@ -94,6 +125,19 @@ jobs:
if-no-files-found: ignore
retention-days: 7
authority-container:
runs-on: ubuntu-22.04
needs: build-test
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate Authority compose file
run: docker compose -f ops/authority/docker-compose.authority.yaml config
- name: Build Authority container image
run: docker build -f ops/authority/Dockerfile -t stellaops-authority:ci .
docs:
runs-on: ubuntu-22.04
env:

1272
StellaOps.sln Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,13 @@
|OSV alias consolidation & per-ecosystem snapshots|BE-Conn-OSV, QA|Merge, Testing|DONE alias graph handles GHSA/CVE records and deterministic snapshots exist across ecosystems.|
|Oracle PSIRT pipeline completion|BE-Conn-Oracle|Source.Common, Core|**DONE** Oracle mapper now emits CVE aliases, vendor affected packages, patch references, and resume/backfill flow is covered by integration tests.|
|VMware connector observability & resume coverage|BE-Conn-VMware, QA|Source.Common, Storage.Mongo|**DONE** VMware diagnostics emit fetch/parse/map metrics, fetch dedupe uses hash cache, and integration test covers snapshot plus resume path.|
|Model provenance & range backlog|BE-Merge|Models|**DOING** VMware/Oracle/Chromium, NVD, Debian, SUSE, Ubuntu, and Adobe emit RangePrimitives (Debian EVR + SUSE NEVRA + Ubuntu EVR telemetry online; Adobe now reports `adobe.track/platform/priority/availability` telemetry with fixed-status provenance). Remaining connectors (Apple, etc.) still need structured primitives/EVR coverage.|
|Trivy DB exporter delta strategy|BE-Export|Exporters|**TODO** finish `ExportStateManager` delta reset and design incremental layer reuse for unchanged trees.|
|Model provenance & range backlog|BE-Merge|Models|**DOING** VMware/Oracle/Chromium, NVD, Debian, SUSE, Ubuntu, Adobe, ICS Kaspersky, CERT-In, CERT-FR, JVN, and KEV now emit RangePrimitives (KEV adds due-date/vendor extensions with deterministic snapshots). Remaining connectors (`Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`) still need structured coverage.|
|Trivy DB exporter delta strategy|BE-Export|Exporters|**DONE** planner promotes chained deltas back to full exports, OCI writer reuses base blobs, regression tests cover the delta→delta→full sequence, and a full-stack layer-reuse smoke test + operator docs landed (2025-10-10).|
|Red Hat fixture validation sweep|QA|Source.Distro.RedHat|**DOING** finalize RHSA fixture regeneration once connector regression fixes land.|
|JVN VULDEF schema update|BE-Conn-JVN, QA|Source.Jvn|**DONE** schema patched (vendor/product attrs, impact entries, err codes), parser tightened, fixtures/tests refreshed.|
|Build/test sweeps|QA|All modules|**DOING** targeted suites green (Models, VMware, Oracle, Chromium, JVN, Cert-In). Full solution run still fails due to `StellaOps.Feedser.Storage.Mongo.Tests/AdvisoryStorePerformanceTests` exceeding perf budget; rerun once budget or test adjusted.|
|OSV vs GHSA parity checks|QA, BE-Merge|Merge|**TODO** design diff detection between OSV and GHSA feeds to surface inconsistencies.|
|Build/test sweeps|QA|All modules|**DONE** wired Authority plugin abstractions into the build, updated CLI export tests for the new overrides, and full `dotnet test` now succeeds (perf suite within budget).|
|Authority plugin PLG1PLG3|BE-Auth Plugin|Authority DevEx|**DONE** abstractions/tests shipped, plugin loader integrated, and Mongo-backed Standard plugin stub operational with bootstrap seeding.|
|Authority plugin PLG4PLG6|BE-Auth Plugin, DevEx/Docs|Authority plugin PLG1PLG3|**READY FOR DOCS REVIEW (2025-10-10)** Capability metadata validated, configuration guardrails shipped, developer guide finalised; waiting on Docs polish + diagram export.|
|Authority plugin PLG7 RFC|BE-Auth Plugin|PLG4|**DRAFTED (2025-10-10)** `docs/rfcs/authority-plugin-ldap.md` captured LDAP plugin architecture, configuration schema, and implementation plan; needs Auth/Security guild review.|
|Feedser modularity test sweep|BE-Conn/QA|Feedser build|**DONE (2025-10-10)** AngleSharp upgrade applied, helper assemblies copy-local, Kaspersky fixtures updated; full `dotnet test src/StellaOps.Feedser.sln` now passes locally.|
|OSV vs GHSA parity checks|QA, BE-Merge|Merge|**DONE** parity inspector/diagnostics wired into OSV connector regression sweep; fixtures validated via `OsvGhsaParityRegressionTests` (see docs/19_TEST_SUITE_OVERVIEW.md) and metrics emitted through `OsvGhsaParityDiagnostics`.|

View File

@@ -4,8 +4,8 @@
## Common
- **Build/test sweeps (QA DOING)**
Full solution runs still fail the `StellaOps.Feedser.Storage.Mongo.Tests/AdvisoryStorePerformanceTests` budget. We need either to optimise the hot paths in `AdvisoryStore` for large advisory payloads or relax the perf thresholds with new baseline data. Once the bottleneck is addressed, rerun the full suite and capture metrics for the release checklist.
- **Build/test sweeps (QA DONE)**
Full `dotnet test` is green again after wiring the Authority plugin abstractions into `StellaOps.Configuration` and updating CLI export tests for the new publish/include overrides. Keep running the sweep weekly and capture timings so we catch regressions early.
- **OSV vs GHSA parity checks (QA & BE-Merge TODO)**
Design and implement a diff detector comparing OSV advisories against GHSA records. The deliverable should flag mismatched aliases, missing affected ranges, or divergent severities, surface actionable telemetry/alerts, and include regression tests with canned OSV+GHSA fixtures.
@@ -13,7 +13,7 @@
## Prerequisites
- **Range primitives for SemVer/EVR/NEVRA metadata (BE-Merge DOING)**
The core model supports range primitives, but several connectors (notably Apple, remaining vendor feeds, and older distro paths) still emit raw strings. We must extend those mappers to populate the structured envelopes (SemVer/EVR/NEVRA plus vendor extensions) and add fixture coverage so merge/export layers see consistent telemetry.
The core model supports range primitives, but several connectors still emit raw strings. Current gaps (snapshot 20251009, post-Kaspersky/CERT-In/CERT-FR/JVN updates): `Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kev`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`. We need to extend those mappers to populate the structured envelopes (SemVer/EVR/NEVRA plus vendor extensions) and add fixture coverage so merge/export layers see consistent telemetry. (Delivered: ICS.Kaspersky, CERT-In, CERT-FR emit vendor primitives; JVN captures version/build metadata.)
- **Provenance envelope field masks (BE-Merge DOING)**
Provenance needs richer categorisation (component category, severity bands, resume counters) and better dedupe metrics. Update the provenance model, extend diagnostics to emit the new tags, and refresh dashboards/tests to ensure determinism once additional metadata flows through.
@@ -27,10 +27,10 @@
Finalise the delta-reset story in `ExportStateManager`: define when to invalidate baselines, how to reuse unchanged layers, and document operator workflows. Implement planner logic for layer reuse, update exporter tests, and exercise a delta→full→delta sequence.
- **Red Hat fixture validation sweep (QA DOING)**
Regenerate RHSA fixtures with the latest connector output and make sure the regenerated snapshots align once the outstanding connector tweaks land. Blockers: connector regression fixes still in-flight; revisit once those merges stabilise to avoid churn.
Regenerate RHSA fixtures with the latest connector output and make sure the regenerated snapshots align once the outstanding connector tweaks land. Pending prerequisites: land the mapper reference-normalisation patch (local branch `redhat/ref-dedupe`) and the range provenance backfill (`RangePrimitives.GetCoverageTag`). Once those land, run `UPDATE_RHSA_FIXTURES=1 dotnet test src/StellaOps.Feedser.Source.Distro.RedHat.Tests/StellaOps.Feedser.Source.Distro.RedHat.Tests.csproj`, review the refreshed `Fixtures/rhsa-*.json`, and sync the task status to **DONE**.
- **Plan incremental/delta exports (BE-Export DOING)**
`TrivyDbExportPlanner` now captures changed files but does not yet reuse existing OCI layers. Extend the planner to build per-file manifests, teach the writer to skip untouched layers, and add delta-cycle tests covering file removals, additions, and checksum changes.
- **Scan execution & result upload workflow (DevEx/CLI & Ops Integrator DOING)**
`stella scan run`/`stella scan upload` need completion: support the remaining executor backends (dotnet/self-hosted/docker), capture structured run metadata, implement retry/backoff on uploads, and add integration tests exercising happy-path and failure retries. Update CLI docs once the workflow is stable.
`stella scan run` now emits a structured `scan-run-*.json` alongside artefacts. Remaining work: add resilient upload retries/backoff, cover success/retry/cancellation with integration tests, and expand docs with docker/dotnet/native runner examples plus metadata troubleshooting tips.

3
WEB-TODOS.md Normal file
View File

@@ -0,0 +1,3 @@
# Web UI Follow-ups
- Trivy DB exporter settings panel: surface `publishFull` / `publishDelta` and `includeFull` / `includeDelta` toggles, saving overrides via future `/exporters/trivy-db/settings` API. Include “run export now” button that reuses those overrides when triggering `export:trivy-db`.

View File

@@ -131,7 +131,11 @@ Each connector ships fixtures/tests under the matching `*.Tests` project.
* JSON exporter mirrors vuln-list layout with per-file digests and manifest.
* Trivy DB exporter shells or native-builds Bolt archives, optionally pushes OCI
layers, and records export cursors.
layers, and records export cursors. Delta runs reuse unchanged blobs from the
previous full baseline, annotating `metadata.json` with `mode`, `baseExportId`,
`baseManifestDigest`, `resetBaseline`, and `delta.changedFiles[]`/`delta.removedPaths[]`.
ORAS pushes honour `publishFull` / `publishDelta`, and offline bundles respect
`includeFull` / `includeDelta` for air-gapped syncs.
###5.4Feedser.WebService

View File

@@ -211,11 +211,14 @@ Configuration follows the same precedence chain everywhere:
| Command | Purpose | Key Flags / Arguments | Notes |
|---------|---------|-----------------------|-------|
| `stellaops-cli scanner download` | Fetch and install scanner container | `--channel <stable\|beta\|nightly>` (default `stable`)<br>`--output <path>`<br>`--overwrite`<br>`--no-install` | Saves artefact under `ScannerCacheDirectory`, verifies digest/signature, and executes `docker load` unless `--no-install` is supplied. |
| `stellaops-cli scan run` | Execute scanner container against a directory (auto-upload) | `--target <directory>` (required)<br>`--runner <docker\|dotnet\|self>` (default from config)<br>`--entry <image-or-entrypoint>`<br>`[scanner-args...]` | Runs the scanner, writes results into `ResultsDirectory`, and automatically uploads the artefact when the exit code is `0`. |
| `stellaops-cli scan run` | Execute scanner container against a directory (auto-upload) | `--target <directory>` (required)<br>`--runner <docker\|dotnet\|self>` (default from config)<br>`--entry <image-or-entrypoint>`<br>`[scanner-args...]` | Runs the scanner, writes results into `ResultsDirectory`, emits a structured `scan-run-*.json` metadata file, and automatically uploads the artefact when the exit code is `0`. |
| `stellaops-cli scan upload` | Re-upload existing scan artefact | `--file <path>` | Useful for retries when automatic upload fails or when operating offline. |
| `stellaops-cli db fetch` | Trigger connector jobs | `--source <id>` (e.g. `redhat`, `osv`)<br>`--stage <fetch\|parse\|map>` (default `fetch`)<br>`--mode <resume|init|cursor>` | Translates to `POST /jobs/source:{source}:{stage}` with `trigger=cli` |
| `stellaops-cli db merge` | Run canonical merge reconcile | — | Calls `POST /jobs/merge:reconcile`; exit code `0` on acceptance, `1` on failures/conflicts |
| `stellaops-cli db export` | Kick JSON / Trivy exports | `--format <json\|trivy-db>` (default `json`)<br>`--delta` | Sets `{ delta = true }` parameter when requested |
| `stellaops-cli db export` | Kick JSON / Trivy exports | `--format <json\|trivy-db>` (default `json`)<br>`--delta`<br>`--publish-full/--publish-delta`<br>`--bundle-full/--bundle-delta` | Sets `{ delta = true }` parameter when requested and can override ORAS/bundle toggles per run |
| `stellaops-cli auth <login\|logout\|status>` | Manage cached tokens for StellaOps Authority | `auth login --force` (ignore cache)<br>`auth status` | Uses `StellaOps.Auth.Client` under the hood; honours `StellaOps:Authority:*` configuration |
When running on an interactive terminal without explicit override flags, the CLI uses Spectre.Console prompts to let you choose per-run ORAS/offline bundle behaviour.
| `stellaops-cli config show` | Display resolved configuration | — | Masks secret values; helpful for airgapped installs |
**Logging & exit codes**
@@ -229,12 +232,51 @@ Configuration follows the same precedence chain everywhere:
- Downloads are verified against the `X-StellaOps-Digest` header (SHA-256). When `StellaOps:ScannerSignaturePublicKeyPath` points to a PEM-encoded RSA key, the optional `X-StellaOps-Signature` header is validated as well.
- Metadata for each bundle is written alongside the artefact (`*.metadata.json`) with digest, signature, source URL, and timestamps.
- Retry behaviour is controlled via `StellaOps:ScannerDownloadAttempts` (default **3** with exponential backoff).
- Successful `scan run` executions create timestamped JSON artefacts inside `ResultsDirectory`; these are posted back to Feedser automatically.
- Successful `scan run` executions create timestamped JSON artefacts inside `ResultsDirectory` plus a `scan-run-*.json` metadata envelope documenting the runner, arguments, timing, and stdout/stderr. The artefact is posted back to Feedser automatically.
#### Trivy DB export metadata (`metadata.json`)
`stellaops-cli db export --format trivy-db` (and the backing `POST /jobs/export:trivy-db`) always emits a `metadata.json` document in the OCI layout root. Operators consuming the bundle or delta updates should inspect the following fields:
| Field | Type | Purpose |
| ----- | ---- | ------- |
| `mode` | `full` \| `delta` | Indicates whether the current run rebuilt the entire database (`full`) or only the changed files (`delta`). |
| `baseExportId` | string? | Export ID of the last full baseline that the delta builds upon. Only present for `mode = delta`. |
| `baseManifestDigest` | string? | SHA-256 digest of the manifest belonging to the baseline OCI layout. |
| `resetBaseline` | boolean | `true` when the exporter rotated the baseline (e.g., repo change, delta chain reset). Treat as a full refresh. |
| `treeDigest` | string | Canonical SHA-256 digest of the JSON tree used to build the database. |
| `treeBytes` | number | Total bytes across exported JSON files. |
| `advisoryCount` | number | Count of advisories included in the export. |
| `exporterVersion` | string | Version stamp of `StellaOps.Feedser.Exporter.TrivyDb`. |
| `builder` | object? | Raw metadata emitted by `trivy-db build` (version, update cadence, etc.). |
| `delta.changedFiles[]` | array | Present when `mode = delta`. Each entry lists `{ "path": "<relative json>", "length": <bytes>, "digest": "sha256:..." }`. |
| `delta.removedPaths[]` | array | Paths that existed in the previous manifest but were removed in the new run. |
When the planner opts for a delta run, the exporter copies unmodified blobs from the baseline layout identified by `baseManifestDigest`. Consumers that cache OCI blobs only need to fetch the `changedFiles` and the new manifest/metadata unless `resetBaseline` is true.
When pushing to ORAS, set `feedser:exporters:trivyDb:oras:publishFull` / `publishDelta` to control whether full or delta runs are copied to the registry. Offline bundles follow the analogous `includeFull` / `includeDelta` switches under `offlineBundle`.
Example configuration (`appsettings.yaml`):
```yaml
feedser:
exporters:
trivyDb:
oras:
enabled: true
publishFull: true
publishDelta: false
offlineBundle:
enabled: true
includeFull: true
includeDelta: false
```
**Authentication**
- API key is sent as `Authorization: Bearer <token>` automatically when configured.
- Anonymous operation (empty key) is permitted for offline use cases but backend calls will fail with 401 unless the Feedser instance allows guest access.
- When `StellaOps:Authority:Url` is set the CLI initialises the StellaOps auth client. Use `stellaops-cli auth login` to obtain a token (password grant when `Username`/`Password` are set, otherwise client credentials). Tokens are cached under `~/.stellaops/tokens` by default; `auth status` shows expiry and `auth logout` removes the cached entry.
**Configuration file template**
@@ -247,7 +289,16 @@ Configuration follows the same precedence chain everywhere:
"ResultsDirectory": "results",
"DefaultRunner": "docker",
"ScannerSignaturePublicKeyPath": "",
"ScannerDownloadAttempts": 3
"ScannerDownloadAttempts": 3,
"Authority": {
"Url": "https://authority.example.org",
"ClientId": "feedser-cli",
"ClientSecret": "REDACTED",
"Username": "",
"Password": "",
"Scope": "feedser.jobs.trigger",
"TokenCacheDirectory": ""
}
}
}
```

View File

@@ -59,6 +59,24 @@ The script spins up MongoDB/Redis via Testcontainers and requires:
---
### Feedser OSV↔GHSA parity fixtures
The Feedser connector suite includes a regression test (`OsvGhsaParityRegressionTests`)
that checks a curated set of GHSA identifiers against OSV responses. The fixture
snapshots live in `src/StellaOps.Feedser.Source.Osv.Tests/Fixtures/` and are kept
deterministic so the parity report remains reproducible.
To refresh the fixtures when GHSA/OSV payloads change:
1. Ensure outbound HTTPS access to `https://api.osv.dev` and `https://api.github.com`.
2. Run `UPDATE_PARITY_FIXTURES=1 dotnet test src/StellaOps.Feedser.Source.Osv.Tests/StellaOps.Feedser.Source.Osv.Tests.csproj`.
3. Commit the regenerated `osv-ghsa.*.json` files that the test emits (raw snapshots and canonical advisories).
The regen flow logs `[Parity]` messages and normalises `recordedAt` timestamps so the
fixtures stay stable across machines.
---
## CI job layout
```mermaid

View File

@@ -160,9 +160,9 @@ public interface IFeedConnector {
## 7) Exporters
* JSON exporter mirrors `aquasecurity/vuln-list` layout with deterministic ordering and reproducible timestamps.
* Trivy DB exporter initially shells out to `trivy-db` builder; later will emit BoltDB directly.
* `StellaOps.Feedser.Storage.Mongo` provides cursors for delta exports based on `export_state.exportCursor`.
* Export jobs produce OCI tarballs (layer media type `application/vnd.aquasec.trivy.db.layer.v1.tar+gzip`) and optionally push via ORAS.
* Trivy DB exporter shells out to `trivy-db build`, produces Bolt archives, and reuses unchanged blobs from the last full baseline when running in delta mode. The exporter annotates `metadata.json` with `mode`, `baseExportId`, `baseManifestDigest`, `resetBaseline`, and `delta.changedFiles[]`/`delta.removedPaths[]`, and honours `publishFull` / `publishDelta` (ORAS) plus `includeFull` / `includeDelta` (offline bundle) toggles.
* `StellaOps.Feedser.Storage.Mongo` provides cursors for delta exports based on `export_state.exportCursor` and the persisted per-file manifest (`export_state.files`).
* Export jobs produce OCI tarballs (layer media type `application/vnd.aquasec.trivy.db.layer.v1.tar+gzip`) and optionally push via ORAS; `metadata.json` accompanies each layout so mirrors can decide between full refreshes and deltas.
---

View File

@@ -0,0 +1,155 @@
# Authority Plug-in Developer Guide
> **Status:** Ready for Docs/DOC4 editorial review as of 2025-10-10. Content aligns with PLG6 acceptance criteria and references stable Authority primitives.
## 1. Overview
Authority plug-ins extend the **StellaOps Authority** service with custom identity providers, credential stores, and client-management logic. Unlike Feedser plug-ins (which ingest or export advisories), Authority plug-ins participate directly in authentication flows:
- **Use cases:** integrate corporate directories (LDAP/AD), delegate to external IDPs, enforce bespoke password/lockout policies, or add client provisioning automation.
- **Constraints:** plug-ins load only during service start (no hot-reload), must function without outbound internet access, and must emit deterministic results for identical configuration and input data.
- **Ship targets:** target the same .NET 10 preview as the host, honour offline-first requirements, and provide clear diagnostics so operators can triage issues from `/ready`.
## 2. Architecture Snapshot
Authority hosts follow a deterministic plug-in lifecycle. The flow below can be rendered as a sequence diagram in the final authored documentation, but all touchpoints are described here for offline viewers:
1. **Configuration load** `AuthorityPluginConfigurationLoader` resolves YAML manifests under `etc/authority.plugins/`.
2. **Assembly discovery** the shared `PluginHost` scans `PluginBinaries/Authority` for `StellaOps.Authority.Plugin.*.dll` assemblies.
3. **Registrar execution** each assembly is searched for `IAuthorityPluginRegistrar` implementations. Registrars bind options, register services, and optionally queue bootstrap tasks.
4. **Runtime** the host resolves `IIdentityProviderPlugin` instances, uses capability metadata to decide which OAuth grants to expose, and invokes health checks for readiness endpoints.
**Data persistence primer:** the standard Mongo-backed plugin stores users in collections named `authority_users_<pluginName>` and lockout metadata in embedded documents. Additional plugins must document their storage layout and provide deterministic collection naming to honour the Offline Kit replication process.
## 3. Capability Metadata
Capability flags let the host reason about what your plug-in supports:
- Declare capabilities in your descriptor using the string constants from `AuthorityPluginCapabilities` (`password`, `mfa`, `clientProvisioning`, `bootstrap`). The configuration loader now validates these tokens and rejects unknown values at startup.
- `AuthorityIdentityProviderCapabilities.FromCapabilities` projects those strings into strongly typed booleans (`SupportsPassword`, etc.). Authority Core will use these flags when wiring flows such as the password grant. Built-in plugins (e.g., Standard) will fail fast or force-enable required capabilities if the descriptor is misconfigured, so keep manifests accurate.
- Typical configuration (`etc/authority.plugins/standard.yaml`):
```yaml
plugins:
descriptors:
standard:
assemblyName: "StellaOps.Authority.Plugin.Standard"
capabilities:
- password
- bootstrap
```
- Only declare a capability if the plug-in genuinely implements it. For example, if `SupportsClientProvisioning` is `true`, the plug-in must supply a working `IClientProvisioningStore`.
**Operational reminder:** the Authority host surfaces capability summaries during startup (see `AuthorityIdentityProviderRegistry` log lines). Use those logs during smoke tests to ensure manifests align with expectations.
## 4. Project Scaffold
- Target **.NET 10 preview**, enable nullable, treat warnings as errors, and mark Authority plug-ins with `<IsAuthorityPlugin>true</IsAuthorityPlugin>`.
- Minimum references:
- `StellaOps.Authority.Plugins.Abstractions` (contracts & capability helpers)
- `StellaOps.Plugin` (hosting/DI helpers)
- `StellaOps.Auth.*` libraries as needed for shared token utilities (optional today).
- Example `.csproj` (trimmed from `StellaOps.Authority.Plugin.Standard`):
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>
```
(Add other references—e.g., MongoDB driver, shared auth libraries—according to your implementation.)
## 5. Implementing `IAuthorityPluginRegistrar`
- Create a parameterless registrar class that returns your plug-in type name via `PluginType`.
- Use `AuthorityPluginRegistrationContext` to:
- Bind options (`AddOptions<T>(pluginName).Bind(...)`).
- Register singletons for stores/enrichers using manifest metadata.
- Register any hosted bootstrap tasks (e.g., seed admin users).
- Always validate configuration inside `PostConfigure` and throw meaningful `InvalidOperationException` to fail fast during startup.
- Use the provided `ILoggerFactory` from DI; avoid static loggers or console writes.
- Example skeleton:
```csharp
internal sealed class MyPluginRegistrar : IAuthorityPluginRegistrar
{
public string PluginType => "my-custom";
public void Register(AuthorityPluginRegistrationContext context)
{
var name = context.Plugin.Manifest.Name;
context.Services.AddOptions<MyPluginOptions>(name)
.Bind(context.Plugin.Configuration)
.PostConfigure(opts => opts.Validate(name));
context.Services.AddSingleton<IIdentityProviderPlugin>(sp =>
new MyIdentityProvider(context.Plugin, sp.GetRequiredService<MyCredentialStore>(),
sp.GetRequiredService<MyClaimsEnricher>(),
sp.GetRequiredService<ILogger<MyIdentityProvider>>()));
}
}
```
## 6. Identity Provider Surface
- Implement `IIdentityProviderPlugin` to expose:
- `IUserCredentialStore` for password validation and user CRUD.
- `IClaimsEnricher` to append roles/attributes onto issued principals.
- Optional `IClientProvisioningStore` for machine-to-machine clients.
- `AuthorityIdentityProviderCapabilities` to advertise supported flows.
- Password guidance:
- Prefer Argon2 (Security Guild upcoming recommendation); Standard plug-in currently ships PBKDF2 with easy swap via `IPasswordHasher`.
- Enforce password policies before hashing to avoid storing weak credentials.
- Health checks should probe backing stores (e.g., Mongo `ping`) and return `AuthorityPluginHealthResult` so `/ready` can surface issues.
- When supporting additional factors (e.g., TOTP), implement `SupportsMfa` and document the enrolment flow for resource servers.
## 7. Configuration & Secrets
- Authority looks for manifests under `etc/authority.plugins/`. Each YAML file maps directly to a plug-in name.
- Support environment overrides using `STELLAOPS_AUTHORITY_PLUGINS__DESCRIPTORS__<NAME>__...`.
- Never store raw secrets in git: allow operators to supply them via `.local.yaml`, environment variables, or injected secret files. Document which keys are mandatory.
- Validate configuration as soon as the registrar runs; use explicit error messages to guide operators. The Standard plug-in now enforces complete bootstrap credentials (username + password) and positive lockout windows via `StandardPluginOptions.Validate`.
- Cross-reference bootstrap workflows with `docs/ops/authority_bootstrap.md` (to be published alongside CORE6) so operators can reuse the same payload formats for manual provisioning.
## 8. Logging, Metrics, and Diagnostics
- Always log via the injected `ILogger<T>`; include `pluginName` and correlation IDs where available.
- Activity/metric names should align with `AuthorityTelemetry` constants (`service.name=stellaops-authority`).
- Expose additional diagnostics via structured logging rather than writing custom HTTP endpoints; the host will integrate these into `/health` and `/ready`.
- Emit metrics with stable names (`auth.plugins.<pluginName>.*`) when introducing custom instrumentation; coordinate with the Observability guild to reserve prefixes.
## 9. Testing & Tooling
- Unit tests: use Mongo2Go (or similar) to exercise credential stores without hitting production infrastructure (`StandardUserCredentialStoreTests` is a template).
- Determinism: fix timestamps to UTC and sort outputs consistently; avoid random GUIDs unless stable.
- Smoke tests: launch `dotnet run --project src/StellaOps.Authority/StellaOps.Authority` with your plug-in under `PluginBinaries/Authority` and verify `/ready`.
- Example verification snippet:
```csharp
[Fact]
public async Task VerifyPasswordAsync_ReturnsSuccess()
{
var store = CreateCredentialStore();
await store.UpsertUserAsync(new AuthorityUserRegistration("alice", "Pa55!", null, null, false,
Array.Empty<string>(), new Dictionary<string, string?>()), CancellationToken.None);
var result = await store.VerifyPasswordAsync("alice", "Pa55!", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(result.User?.Roles.Count == 0);
}
```
## 10. Packaging & Delivery
- Output assembly should follow `StellaOps.Authority.Plugin.<Name>.dll` so the hosts search pattern picks it up.
- Place the compiled DLL plus dependencies under `PluginBinaries/Authority` for offline deployments; include hashes/signatures in release notes (Security Guild guidance forthcoming).
- Document any external prerequisites (e.g., CA cert bundle) in your plug-in README.
- Update `etc/authority.plugins/<plugin>.yaml` samples and include deterministic SHA256 hashes for optional bootstrap payloads when distributing Offline Kit artefacts.
## 11. Checklist & Handoff
- ✅ Capabilities declared and validated in automated tests.
- ✅ Bootstrap workflows documented (if `bootstrap` capability used) and repeatable.
- ✅ Local smoke test + unit/integration suites green (`dotnet test`).
- ✅ Operational docs updated: configuration keys, secrets guidance, troubleshooting.
- Submit the developer guide update referencing PLG6/DOC4 and tag DevEx + Docs reviewers for sign-off.
---
**Next documentation actions:**
- Add rendered architectural diagram (PlantUML/mermaid) reflecting the lifecycle above once the Docs toolkit pipeline is ready.
- Reference the LDAP RFC (`docs/rfcs/authority-plugin-ldap.md`) in the capability section once review completes.
- Sync terminology with `docs/11_AUTHORITY.md` when that chapter is published to keep glossary terms consistent.

View File

@@ -0,0 +1,136 @@
# RFC: StellaOps.Authority.Plugin.Ldap
**Status:** Draft for review by Auth Guild, Security Guild, DevEx (2025-10-10)
**Authors:** Plugin Team 4 (Auth Libraries & Identity Providers)
**Related initiatives:** PLG7 backlog, CORE5 event handlers, DOC4 developer guide
## 1. Problem Statement
Many on-prem StellaOps deployments rely on existing LDAP/Active Directory domains for workforce identity. The current Standard Mongo-backed plugin requires duplicating users and secrets, which increases operational overhead and violates corporate policy in some regulated environments. We need a sovereign, offline-friendly LDAP plugin that:
- Supports password grant and bootstrap provisioning flows without storing credentials in Mongo.
- Enforces StellaOps security policies (lockout, password policy hints, audit logging) while delegating credential validation to LDAP.
- Operates deterministically in offline or partially connected environments by caching directory metadata when necessary.
## 2. Goals
- Provide a first-party `StellaOps.Authority.Plugin.Ldap` plugin advertising `password` and optional `clientProvisioning` capabilities at launch.
- Support username/password authentication against LDAP bind operations with configurable DN templates.
- Allow optional bootstrap seeding of service accounts by writing into LDAP (guarded behind explicit configuration) or by mapping to pre-existing entries.
- Surface directory-derived claims (groups, attributes) for downstream authorization via `IClaimsEnricher`.
- Integrate with Authority lockout telemetry and structured logging without persisting secrets locally.
## 3. Non-Goals
- Implement multi-factor authentication out of the box (future enhancement once TOTP/WebAuthn strategy is finalised).
- Provide write-heavy directory management (e.g., user creation workflows) beyond optional bootstrap service account seeding.
- Replace the Standard plugin; both must remain supported and selectable per environment.
## 4. Key Constraints & Assumptions
- Offline-first posture: deployments may operate without outbound internet and with intermittent directory connectivity (e.g., read-only replicas). The plugin must tolerate transient LDAP connectivity failures and degrade gracefully.
- Deterministic behaviour: identical configuration and directory state must yield identical token issuance results. Cached metadata (e.g., group lookups) must have defined expiration.
- Security: No plaintext credential storage; TLS must be enforced for LDAP connections unless explicitly overridden for air-gapped lab environments.
## 5. High-Level Architecture
1. **Configuration binding** (`ldap.yaml`): defines server endpoints, bind strategy, claim mapping, and optional bootstrap overrides.
2. **Connection factory**: pooled LDAP connections using a resilient client (preferred dependency: `Novell.Directory.Ldap.NETStandard`).
3. **Credential validator** (`IUserCredentialStore`): performs bind-as-user flow with optional fallback bind using service account when directories disallow anonymous search.
4. **Claims enricher** (`IClaimsEnricher`): queries group membership/attributes and projects them into canonical roles/claims.
5. **Optional client provisioning** (`IClientProvisioningStore`): maintains machine/service principals either in Mongo (metadata) or via LDAP `serviceConnectionPoint` entries based on configuration.
6. **Health checks**: periodic LDAP `whoami` or `search` probes surfaced through `AuthorityPluginHealthResult`.
```
Authority Host
├── Plugin Manifest (ldap)
├── Registrar → registers ConnectionFactory, LdapCredentialStore, LdapClaimsEnricher
├── Password Grant Handler → CredentialStore.VerifyPasswordAsync → LDAP Bind
└── Claims Pipeline → ClaimsEnricher.EnrichAsync → LDAP group lookup
```
## 6. Configuration Schema (Draft)
```yaml
connection:
host: "ldaps://ldap.example.internal"
port: 636
useStartTls: false
validateCertificates: true
bindDn: "cn=stellaops-bind,ou=service,dc=example,dc=internal"
bindPasswordSecret: "file:/etc/stellaops/secrets/ldap-bind.txt"
searchBase: "dc=example,dc=internal"
usernameAttribute: "uid"
userDnFormat: "uid={username},ou=people,dc=example,dc=internal" # optional template
security:
requireTls: true
allowedCipherSuites: [] # optional allow-list
referralChasing: false
lockout:
useAuthorityPolicies: true # reuse Authority lockout counters
directoryLockoutAttribute: "pwdAccountLockedTime"
claims:
groupAttribute: "memberOf"
groupToRoleMap:
"cn=stellaops-admins,ou=groups,dc=example,dc=internal": "operators"
"cn=stellaops-read,ou=groups,dc=example,dc=internal": "auditors"
extraAttributes:
displayName: "displayName"
email: "mail"
clientProvisioning:
enabled: false
containerDn: "ou=service,dc=example,dc=internal"
secretAttribute: "userPassword"
health:
probeIntervalSeconds: 60
timeoutSeconds: 5
```
## 7. Capability Mapping
| Capability | Implementation Notes |
|------------|---------------------|
| `password` | Bind-as-user validation with Authority lockout integration. Mandatory. |
| `clientProvisioning` | Optional; when enabled, creates/updates LDAP entries for machine clients or stores metadata in Mongo if directory writes are disabled. |
| `bootstrap` | Exposed only when bootstrap manifest provides service account credentials AND directory write permissions are confirmed during startup. |
| `mfa` | Not supported in MVP. Future iteration may integrate TOTP attributes or external MFA providers. |
## 8. Operational Considerations
- **Offline cache:** provide optional Mongo cache for group membership to keep `/ready` responsive if LDAP is temporarily unreachable. Cache entries must include TTL and invalidation hooks.
- **Secrets management:** accept `file:` and environment variable references; integrate with existing `StellaOps.Configuration` secret providers.
- **Observability:** emit structured logs with event IDs (`LDAP_BIND_START`, `LDAP_BIND_FAILURE`, `LDAP_GROUP_LOOKUP`), counters for success/failure, and latency histograms.
- **Throttling:** reuse Authority rate-limiting middleware; add per-connection throttles to avoid saturating directory servers during brute-force attacks.
## 9. Security & Compliance
- Enforce TLS (`ldaps://` or STARTTLS) by default. Provide explicit `allowInsecure` flag gated behind environment variable for lab/testing only.
- Support password hash migration by detecting directory lockout attributes and surfacing `RequiresPasswordReset` when policies demand changes.
- Log distinguished names only at `Debug` level to avoid leaking sensitive structure in default logs.
- Coordinate with Security Guild for penetration testing before GA; incorporate audit log entries for bind attempts and provisioning changes.
## 10. Testing Strategy
- **Unit tests:** mock LDAP connections to validate DN formatting, error mapping, and capability negotiation.
- **Integration tests:** run against an ephemeral OpenLDAP container (seeded via LDIF fixtures) within CI. Include offline cache regression (disconnect LDAP mid-test).
- **Determinism tests:** feed identical LDIF snapshots and configuration to ensure output tokens/claims remain stable across runs.
- **Smoke tests:** `dotnet test` harness plus manual `dotnet run` scenario verifying `/token` password grants and `/internal/users` bootstrap with LDAP-backed store.
## 11. Implementation Plan
1. Scaffold `StellaOps.Authority.Plugin.Ldap` project + tests (net10.0, `<IsAuthorityPlugin>` true).
2. Implement configuration options + validation (mirroring Standard plugin guardrails).
3. Build connection factory + credential store with bind logic.
4. Implement claims enricher and optional cache layer.
5. Add client provisioning store (optional) with toggles for read-only deployments.
6. Wire bootstrapper to validate connectivity/permissions and record findings in startup logs.
7. Extend developer guide with LDAP specifics (post-RFC acceptance).
8. Update Docs and TODO trackers; produce release notes entry once merged.
## 12. Open Questions
- Should client provisioning default to storing metadata in Mongo even when LDAP writes succeed (to preserve audit history)?
- Do we require LDAPS mutual TLS support (client certificates) for regulated environments? If yes, need to extend configuration schema.
- How will we map LDAP groups to Authority scopes/roles when names differ significantly? Consider supporting regex or mapping scripts.
## 13. Timeline (Tentative)
- **Week 1:** RFC review & sign-off.
- **Week 2-3:** Implementation & unit tests.
- **Week 4:** Integration tests + documentation updates.
- **Week 5:** Security review, release candidate packaging.
## 14. Approval
- **Auth Guild Lead:** _TBD_
- **Security Guild Representative:** _TBD_
- **DevEx Docs:** _TBD_
---
Please add comments inline or via PR review. Once approved, track execution under PLG7.

View File

@@ -0,0 +1,17 @@
# Placeholder configuration for the LDAP identity provider plug-in.
# Replace values with your directory settings before enabling the plug-in.
connection:
host: "ldap.example.com"
port: 636
useTls: true
bindDn: "cn=service,dc=example,dc=com"
bindPassword: "CHANGE_ME"
queries:
userFilter: "(uid={username})"
groupFilter: "(member={distinguishedName})"
groupAttribute: "cn"
capabilities:
supportsPassword: true
supportsMfa: false

View File

@@ -0,0 +1,21 @@
# Standard plugin configuration (Mongo-backed identity store).
bootstrapUser:
username: "admin"
password: "changeme"
passwordPolicy:
minimumLength: 12
requireUppercase: true
requireLowercase: true
requireDigit: true
requireSymbol: true
lockout:
enabled: true
maxAttempts: 5
windowMinutes: 15
tokenSigning:
# Path to the directory containing signing keys (relative paths resolve
# against this configuration file location).
keyDirectory: "../keys"

71
etc/authority.yaml.sample Normal file
View File

@@ -0,0 +1,71 @@
# StellaOps Authority configuration template.
# Copy to ../etc/authority.yaml (relative to the Authority content root)
# and adjust values to fit your environment. Environment variables
# prefixed with STELLAOPS_AUTHORITY_ override these values at runtime.
# Example: STELLAOPS_AUTHORITY__ISSUER=https://authority.example.com
schemaVersion: 1
# Absolute issuer URI advertised to clients. Use HTTPS for anything
# beyond loopback development.
issuer: "https://authority.stella-ops.local"
# Token lifetimes expressed as HH:MM:SS or DD.HH:MM:SS.
accessTokenLifetime: "00:15:00"
refreshTokenLifetime: "30.00:00:00"
identityTokenLifetime: "00:05:00"
authorizationCodeLifetime: "00:05:00"
deviceCodeLifetime: "00:15:00"
# MongoDB storage connection details.
storage:
connectionString: "mongodb://localhost:27017/stellaops-authority"
# databaseName: "stellaops_authority"
commandTimeout: "00:00:30"
# Bootstrap administrative endpoints (initial provisioning).
bootstrap:
enabled: false
apiKey: "change-me"
defaultIdentityProvider: "standard"
# Directories scanned for Authority plug-ins. Relative paths resolve
# against the application content root, enabling air-gapped deployments
# that package plug-ins alongside binaries.
pluginDirectories:
- "../PluginBinaries/Authority"
# "/var/lib/stellaops/authority/plugins"
# Plug-in manifests live in descriptors below; per-plugin settings are stored
# in the configurationDirectory (YAML files). Authority will load any enabled
# plugins and surface their metadata/capabilities to the host.
plugins:
configurationDirectory: "../etc/authority.plugins"
descriptors:
standard:
type: "standard"
assemblyName: "StellaOps.Authority.Plugin.Standard"
enabled: true
configFile: "standard.yaml"
capabilities:
- password
- bootstrap
- clientProvisioning
metadata:
defaultRole: "operators"
# Example for an external identity provider plugin. Leave disabled unless
# the plug-in package exists under PluginBinaries/Authority.
ldap:
type: "ldap"
assemblyName: "StellaOps.Authority.Plugin.Ldap"
enabled: false
configFile: "ldap.yaml"
capabilities:
- password
- mfa
# CIDR ranges that bypass network-sensitive policies (e.g. on-host cron jobs).
# Keep the list tight: localhost is sufficient for most air-gapped installs.
bypassNetworks:
- "127.0.0.1/32"
- "::1/128"

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Content Remove="NuGet.config" />
<None Include="NuGet.config">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.Client" Version="6.4.0" />
<PackageReference Include="OpenIddict.Client.AspNetCore" Version="6.4.0" />
<PackageReference Include="OpenIddict.Client.DataProtection" Version="6.4.0" />
<PackageReference Include="OpenIddict.Client.SystemNetHttp" Version="6.4.0" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="6.4.0" />
<PackageReference Include="OpenIddict.Validation.DataProtection" Version="6.4.0" />
<PackageReference Include="OpenIddict.Validation.SystemNetHttp" Version="6.4.0" />
<PackageReference Include="Polly" Version="8.6.1" />
<PackageReference Include="System.Text.Encodings.Web" Version="9.0.6" />
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="6.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.3.0" />
<PackageReference Include="OpenIddict.Abstractions" Version="6.4.0" />
<PackageReference Include="System.Text.Json" Version="9.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Cors" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Ablera.Serdica.Common.Tools/Ablera.Serdica.Common.Tools.csproj" />
<ProjectReference Include="../Ablera.Serdica.Extensions.Redis/Ablera.Serdica.Extensions.Redis.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using System.Collections.Immutable;
namespace Ablera.Serdica.Authentication.Constants;
public static class ConstantsClass
{
public const string HttpContextItemsSession = "Session";
public const string HttpContextEndpoint = "Endpoint";
public const string HttpContextEndpointRequiredRoles = "EndpointRequiredRoles";
public const string RedisKeyPrefixKey = "serdica-session-dp";
public const string DataProtectionApplicationName = "SerdicaAuth";
public const string AuthenticationScheme = "SerdicaAuthentication"; // "SerdicaAuthentication"
public const string SerdicaAPIAudience = "SerdicaAPI";
public const string DefaultRolePrincipalPrefix = "__principal";
}

View File

@@ -0,0 +1,10 @@
namespace Ablera.Serdica.Authentication.Constants
{
public static class SerdicaClaims
{
public const string Anonymous = "__anonymous";
public const string IsAuthenticated = "__isAuthenticated";
public const string DefaultIdentity = "__default";
public const string RoleSuperUser = "DBA";
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Validation.AspNetCore;
using OpenIddict.Validation.SystemNetHttp;
using StackExchange.Redis;
using Ablera.Serdica.Authentication.Models;
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Authentication.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Ablera.Serdica.Authentication.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Ablera.Serdica.Authentication.Constants;
using OpenIddict.Client;
using OpenIddict.Validation;
using System.Linq;
using System.Collections.Generic;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using System.Security.Principal;
using OpenIddict.Client.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Ablera.Serdica.DependencyInjection;
using static Ablera.Serdica.Authentication.Constants.ConstantsClass;
using static OpenIddict.Abstractions.OpenIddictConstants;
using System.IdentityModel.Tokens.Jwt;
using static OpenIddict.Client.OpenIddictClientEvents;
namespace Ablera.Serdica.DependencyInjection;
public sealed class AcceptAnyIssuer :
IOpenIddictClientHandler<OpenIddict.Client.OpenIddictClientEvents.HandleConfigurationResponseContext>
{
public ValueTask HandleAsync(HandleConfigurationResponseContext ctx)
{
// Short-circuit the built-in ValidateIssuer handler.
ctx.SkipRequest();
return default;
}
}
public static class JwtBearerWithSessionAuthenticationExtensions
{
public static IServiceCollection AddDataProtection(this IServiceCollection services, IConfiguration configuration)
{
//------------------------------------------------------------------
// 1) read configuration
//------------------------------------------------------------------
var redisConfiguration = RedisConfigurationGetter.GetRedisConfiguration(configuration);
var multiplexer = ConnectionMultiplexer.Connect(redisConfiguration);
services.AddSingleton<IConnectionMultiplexer>(multiplexer);
//------------------------------------------------------------------
// 2) Data-Protection (encrypt/sign cookies) keys stored in Redis
//------------------------------------------------------------------
var xmlRepo = new RedisAndFileSystemXmlRepository(
multiplexer.GetDatabase(), RedisKeyPrefixKey);
services.AddDataProtection()
.SetApplicationName(DataProtectionApplicationName)
.PersistKeysToStackExchangeRedis(multiplexer, RedisKeyPrefixKey)
.AddKeyManagementOptions(o => o.XmlRepository = xmlRepo)
.SetDefaultKeyLifetime(TimeSpan.FromDays(30));
return services;
}
public static IServiceCollection AddMicroserviceAuthentication(
this IServiceCollection services,
IConfiguration cfg,
IHostEnvironment env)
{
// ---------------------------------------------------------------------
// 1) Read and validate the OIDC client settings
// ---------------------------------------------------------------------
var oidc = cfg.GetSection(nameof(OidcValidation)).Get<OidcValidation>()
?? throw new InvalidOperationException($"{nameof(OidcValidation)} section is missing.");
if (string.IsNullOrWhiteSpace(oidc.EncryptionKey))
throw new InvalidOperationException($"{nameof(oidc.EncryptionKey)} is not defined.");
// Issuer value found in the `iss` claim of the tokens (HTTPS as issued by the IdP)
var issuerUrl = new Uri(oidc.IssuerUrl
?? throw new InvalidOperationException($"{nameof(oidc.IssuerUrl)} is not defined."));
services.Configure<OidcValidation>(cfg.GetSection(nameof(OidcValidation)));
services
.AddDataProtection(cfg)
.AddOpenIddict()
.AddValidation(opt =>
{
opt.UseSystemNetHttp();
opt.UseAspNetCore();
opt.SetIssuer(issuerUrl);
if (!string.IsNullOrWhiteSpace(oidc.ConfigurationUrl))
{
opt.Configure(x =>
{
x.ConfigurationEndpoint = new Uri(oidc.ConfigurationUrl);
});
}
opt.AddEncryptionKey(
new SymmetricSecurityKey(Convert.FromBase64String(oidc.EncryptionKey)));
});
services.AddAuthorization(options =>
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build())
.AddAuthentication(options =>
{
options.DefaultScheme = ConstantsClass.AuthenticationScheme;
options.DefaultChallengeScheme = ConstantsClass.AuthenticationScheme;
})
.AddScheme<JwtBearerOptions, SerdicaJwtBearerAuthenticationHandler>(
ConstantsClass.AuthenticationScheme, _ => { });
return services;
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http;
using NetTools;
using System;
using System.Linq;
using System.Net;
using System.Collections.Generic;
using Ablera.Serdica.Authentication.Utilities;
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Common.Tools.Utilities;
namespace Ablera.Serdica.Authority.Extensions;
public static class AllowedMaskExtensions
{
// Lazily built the first time AllowedMaskExtensions is referenced.
private static readonly IReadOnlyCollection<IPAddressRange> AssociatedNetworks = ListeningNetworksRetriever.Retrieve();
public static AllowedMask? MergeWith(this AllowedMask? client, AllowedMask? global)
=> (client, global) switch
{
(null, null) => null,
(null, _) => global,
_ => new()
{
SameNetworks = client.SameNetworks ?? global?.SameNetworks,
Networks = client.Networks ?? global?.Networks,
Hosts = client.Hosts ?? global?.Hosts,
Ports = client.Ports ?? global?.Ports
}
};
public static bool MatchesRemote(this AllowedMask allow, HttpContext http)
{
var remoteIp = http.Connection.RemoteIpAddress ?? IPAddress.None;
var host = http.Request.Host.Host;
var port = http.Request.Host.Port ?? 0;
bool ipOk = allow.Networks == null ||
allow.Networks.Any(net => IPAddressRange.Parse(net).Contains(remoteIp));
bool hostOk = allow.Hosts == null ||
allow.Hosts.Any(h => StringComparer.OrdinalIgnoreCase.Equals(h, host));
bool portOk = allow.Ports == null || allow.Ports.Contains(port);
// Same-network rule: only enforced when SameNetwork == true
bool sameNetworkOk =
allow.SameNetworks != true || // Flag not enabled → no restriction
AssociatedNetworks == null || // Could not determine our own network
AssociatedNetworks.Any(network => network.Contains(remoteIp));
return ipOk && hostOk && portOk && sameNetworkOk;
}
}

View File

@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Ablera.Serdica.Authentication.Extensions
{
public static class ClaimExtensions
{
public static IReadOnlyCollection<Claim> BuildClaims<TKeyType>(
this IdentityUser<TKeyType> identity,
string? userName = null, string? givenName = null, string? surname = null)
where TKeyType : IEquatable<TKeyType> => new[]
{
new Claim(ClaimTypes.NameIdentifier, identity.Id?.ToString() ?? string.Empty),
new Claim(Claims.Subject, identity.Id?.ToString() ?? string.Empty),
new Claim(ClaimTypes.Name, userName ?? identity.UserName ?? string.Empty),
new Claim(ClaimTypes.GivenName, givenName ?? string.Empty),
new Claim(ClaimTypes.Surname, surname ?? string.Empty),
new Claim(ClaimTypes.Email, identity.Email ?? string.Empty)
};
public static IEnumerable<string> DestinationsSelector(this Claim c) => c.Type switch
{
Claims.Name or Claims.PreferredUsername
=> new[] { Destinations.AccessToken, Destinations.IdentityToken },
Claims.Email when c.Subject?.HasScope(Scopes.Email) == true
=> new[] { Destinations.AccessToken, Destinations.IdentityToken },
Claims.Role when c.Subject?.HasScope(Scopes.Roles) == true
=> new[] { Destinations.AccessToken, Destinations.IdentityToken },
_ => new[] { Destinations.AccessToken }
};
public static string? GetUserId(this ClaimsPrincipal user)
=> user.Claims.GetUserId() ?? Guid.Empty.ToString();
public static string? GetUserEmail(this ClaimsPrincipal user)
=> user.Claims
.FirstOrDefault(x => x.Type == ClaimTypes.Email)
?.Value?.ToString();
private static string? GetUserId(this IEnumerable<Claim> claims)
=> claims
.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier)
?.Value?.ToString()
?? claims
.FirstOrDefault(x => x.Type == ClaimTypes.Name)
?.Value?.ToString();
public static string? GetClientApplicationId(this ClaimsPrincipal user)
=> user.Claims.GetClientApplicationId();
private static string? GetClientApplicationId(this IEnumerable<Claim> claims)
=> claims
.FirstOrDefault(x => x.Type == Claims.Subject)
?.Value?.ToString()
?? claims
.FirstOrDefault(x => x.Type == Claims.ClientId)
?.Value?.ToString();
}
}

View File

@@ -0,0 +1,16 @@
using System.Security.Claims;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Ablera.Serdica.Authentication.Extensions;
public static class PrincipalBuilder
{
public static ClaimsPrincipal Build(string clientId, string authenticationSchema)
{
var claimsIdentity = new ClaimsIdentity(authenticationSchema);
claimsIdentity.AddClaim(Claims.Subject, clientId, Destinations.AccessToken);
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
return claimsPrincipal;
}
}

View File

@@ -0,0 +1,18 @@
using Ablera.Serdica.Authentication.Models;
using Ablera.Serdica.Common.Tools.Extensions;
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authentication.Extensions;
public static class ProxyResultExtension
{
public static async Task ReturnHttpRessponse(this ProxyResult proxyResult, HttpResponse httpResponse)
{
if (httpResponse.HasStarted) return;
httpResponse.StatusCode = (int)proxyResult.StatusCode;
httpResponse.ContentType = "application/json";
await JsonSerializer.SerializeAsync(httpResponse.Body, proxyResult, proxyResult.GetType(), GlobalJsonSerializerOptions.JsonSerializerOptions);
}
}

View File

@@ -0,0 +1,10 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record AllowedMask
{
public bool? SameNetworks { get; init; }
public string[]? Hosts { get; init; }
public string[]? Networks { get; init; }
public int[]? Ports { get; init; }
public string[]? ClientIds { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record ClaimTypeAndValue
{
public required string Type { get; init; } = null!;
public required string Value { get; init; } = null!;
}

View File

@@ -0,0 +1,8 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record ClientCredentials : ConnectionSettingsBase
{
public required string[] Scopes { get; init; }
public required string[] Claims { get; init; }
public bool RequireHttps { get; init; } = true;
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Text.Json;
namespace Ablera.Serdica.Authentication.Models.Oidc;
public abstract record ConnectionSettingsBase
{
public required string[] GrantTypes { get; set; }
public required string ClientId { get; init; }
public string? ClientSecret { get; init; }
public required string ClientType { get; init; } = "public";
public required string DisplayName { get; init; }
public string[]? RedirectUris { get; init; }
public string[]? PostLogoutRedirectUris { get; init; }
public Dictionary<string, JsonElement>? Properties { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace Ablera.Serdica.Authority.Models;
public record Endpoints
{
public required string Authorization { get; init; } = "/connect/authorize";
public required string Introspection { get; init; } = "/connect/introspect";
public required string Token { get; init; } = "/connect/token";
public required string Userinfo { get; init; } = "/connect/userinfo";
public required string EndUserVerification { get; init; } = "/connect/verification";
public required string Revocation { get; init; } = "/connect/revocation";
public required string Logout { get; init; } = "/connect/endsession";
public required string CheckSession { get; init; } = "/connect/checksession";
public required string Device { get; init; } = "/connect/device";
public required string Jwks { get; init; } = "/connect/jwks";
public required string Configuration { get; init; } = "/.well-known/openid-configuration";
}

View File

@@ -0,0 +1,15 @@
using Ablera.Serdica.Authority.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record OidcValidation : OidcSettingsBase
{
public required string IssuerUrl { get; set; }
public required string? ConfigurationUrl { get; set; }
public AllowedMask[] BypassValidationsMasks { get; init; } = Array.Empty<AllowedMask>();
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Models.Oidc;
namespace Ablera.Serdica.Authority.Models;
public record OidcServerSettings : OidcSettingsBase
{
public Endpoints Endpoints { get; init; } = null!;
public required string IssuerUrl { get; init; } = null!;
public bool? RequireHttps { get; set; } = false;
public required string CookieName { get; init; } = "oauth2-authorization";
public required int CookieExpirationInMinutes { get; init; } = 2;
public required int AuthorizationTokenDurationInMinutes { get; init; } = 5;
public RegisteredClient[] RegisteredClients { get; init; } = Array.Empty<RegisteredClient>();
public string[] Claims { get; init; } = Array.Empty<string>();
public string[] Scopes { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,7 @@
namespace Ablera.Serdica.Authentication.Models.Oidc;
public abstract record OidcSettingsBase
{
public string? EncryptionKey { get; init; }
public AllowedMask[]? AllowedMasks { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace Ablera.Serdica.Authentication.Models.Oidc;
public record RegisteredClient : ConnectionSettingsBase
{
public string[]? Permissions { get; init; }
public string[]? Requirements { get; init; }
public AllowedMask[]? AllowedMasks { get; init; }
public ClaimTypeAndValue[]? BuiltinClaims { get; init; } = [];
public Dictionary<string, string?>? Settings { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Net;
using System.Text.Json.Nodes;
namespace Ablera.Serdica.Authentication.Models;
public sealed class ProxyResult
{
public HttpStatusCode StatusCode { get; init; } = HttpStatusCode.OK;
public JsonNode? Data { get; init; } // null ⇒ no body
public IDictionary<string, string>? Errors { get; init; }
public string? TraceId { get; init; }
public string? Title { get; init; }
public string? Type { get; init; }
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget-mirror" value="https://mirrors.ablera.dev/nuget/nuget-mirror/v3/index.json" />
<add key="GitlabSerdicaBackend" value="https://gitlab.ablera.dev/api/v4/projects/92/packages/nuget/index.json" />
</packageSources>
<packageSourceCredentials>
<GitlabSerdicaBackend>
<add key="Username" value="gitlab+deploy-token-3" />
<add key="ClearTextPassword" value="osdy7Ec2sVoSJC2Kaxvr" />
</GitlabSerdicaBackend>
</packageSourceCredentials>
</configuration>

View File

@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.Authentication.Models;
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Authority.Extensions;
using System.Net;
using OpenIddict.Validation.AspNetCore;
using Ablera.Serdica.Authentication.Extensions;
using static Ablera.Serdica.Authentication.Constants.ConstantsClass;
namespace Ablera.Serdica.Authentication.Services;
public sealed class SerdicaJwtBearerAuthenticationHandler : AuthenticationHandler<JwtBearerOptions>
{
private readonly OidcValidation oidcValidationSettings;
private readonly ILogger<SerdicaJwtBearerAuthenticationHandler> logger;
public SerdicaJwtBearerAuthenticationHandler(
IOptionsMonitor<JwtBearerOptions> jwtOptions,
ILoggerFactory loggerFactory,
ILogger<SerdicaJwtBearerAuthenticationHandler> logger,
UrlEncoder encoder,
IOptions<OidcValidation> oidcServerConnection)
: base(jwtOptions, loggerFactory, encoder)
{
this.oidcValidationSettings = oidcServerConnection.Value;
this.logger = logger;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 1. Internal callers detected by bypass mask → Super user
if (oidcValidationSettings.BypassValidationsMasks?
.Any(m => m.MatchesRemote(Context)) == true)
{
return SuccessTicket(BuildDefaultRolePrincipal(SerdicaClaims.RoleSuperUser));
}
// 2. What roles does the endpoint require?
Context.Items.TryGetValue(ConstantsClass.HttpContextEndpointRequiredRoles,
out var rolesObj);
var requiredRoles = rolesObj as string[];
if (requiredRoles is { Length: 0 }) // empty means requirement for authentication claim
{
requiredRoles =
[
SerdicaClaims.IsAuthenticated
];
}
bool anonymousAllowed = requiredRoles == null ||
requiredRoles.Contains(SerdicaClaims.Anonymous,
StringComparer.Ordinal);
// 3. Decide whether we *need* to run AuthenticateAsync
bool tokenPresent =
Context.Request.Headers.TryGetValue("Authorization", out var authHeaders) &&
authHeaders.Any(h => h?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true);
bool mustAuthenticate = tokenPresent || !anonymousAllowed;
AuthenticateResult authResult = mustAuthenticate
? await Context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)
: AuthenticateResult.NoResult(); // cheap placeholder; not succeeded, not failed
logger.LogInformation(
"Authorizing with following parameters authResult: {AuthResult}, anonymousAllowed: {anonymousAllowed}, tokenPresent: {tokenPresent}, requiredRoles: {requiredRoles}, roleClaims: {roleClaims}",
authResult.Succeeded,
anonymousAllowed,
tokenPresent,
string.Join(",", requiredRoles ?? []),
string.Join(",", authResult?.Principal?.Claims?.Where(c => c.Type == ClaimTypes.Role)?.Select(c => c.Value) ?? [])
);
// 4. Figure out whether roles are satisfied (only matters if authenticated)
bool rolesSatisfied = authResult?.Succeeded == true &&
!anonymousAllowed &&
requiredRoles is { Length: > 0 } &&
(requiredRoles.Contains(SerdicaClaims.IsAuthenticated)
||
(authResult?.Principal?.Claims
?.Where(c => c.Type == ClaimTypes.Role)
?.Select(c => c.Value)
?.Intersect(requiredRoles!)
?.Any() ?? false) == true);
// 5. Switch expression drives the outcome
return (anonymousAllowed, authResult?.Succeeded ?? false, rolesSatisfied) switch
{
// Anonymous endpoint
(true, true, _) => SuccessTicket(authResult!.Principal!), // token supplied
(true, false, _) => SuccessTicket(BuildDefaultRolePrincipal(
SerdicaClaims.Anonymous)), // no token
// Protected endpoint but NOT authenticated
(false, false, _) => AuthenticateResult.Fail(
authResult!.Failure ?? new Exception("Token invalid.")),
// Authenticated but lacks required roles
(_, _, false) => AuthenticateResult.Fail("Insufficient privileges"),
// Authenticated and authorised
_ => SuccessTicket(authResult!.Principal!)
};
}
protected override async Task HandleChallengeAsync(AuthenticationProperties props)
{
var proxy = new ProxyResult
{
StatusCode = HttpStatusCode.Unauthorized, // 401
TraceId = Context.TraceIdentifier,
Title = "Unauthorized",
Type = "https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.2",
Errors = new Dictionary<string, string>
{
["authentication"] = "Missing or invalid credentials."
}
};
await proxy.ReturnHttpRessponse(Response);
}
protected override async Task HandleForbiddenAsync(AuthenticationProperties props)
{
var proxy = new ProxyResult
{
StatusCode = HttpStatusCode.Forbidden, // 403
TraceId = Context.TraceIdentifier,
Title = "Forbidden",
Type = "https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.3",
Errors = new Dictionary<string, string>
{
["authorization"] = "Insufficient privileges."
}
};
await proxy.ReturnHttpRessponse(Response);
}
// ──────────────────────────────────────────────────────────────────
private ClaimsPrincipal BuildDefaultRolePrincipal(string role) =>
PrincipalBuilder.Build($"{DefaultRolePrincipalPrefix}_{role}", ConstantsClass.AuthenticationScheme)
.AddClaim(ClaimTypes.NameIdentifier, $"{DefaultRolePrincipalPrefix}_{role}")
.AddClaim(ClaimTypes.Role, role);
private static AuthenticateResult SuccessTicket(ClaimsPrincipal principal)
=> AuthenticateResult.Success(
new AuthenticationTicket(
principal,
principal.Identity!.AuthenticationType!
)
);
}

View File

@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.DataProtection.Repositories;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;
namespace Ablera.Serdica.Authentication.Utilities;
// Move this to ...Authentication.Redis or something
public sealed class RedisAndFileSystemXmlRepository : IXmlRepository
{
private readonly IDatabase _db;
private readonly string _prefix;
public RedisAndFileSystemXmlRepository(IDatabase db, string prefix)
{
_db = db;
_prefix = prefix;
}
public IReadOnlyCollection<XElement> GetAllElements()
{
var keys = _db.SetMembers(_prefix);
var list = new List<XElement>();
foreach (var redisValue in keys)
{
var xml = redisValue.ToString();
try { list.Add(XElement.Parse(xml)); }
catch { /* ignore corrupted entry */ }
}
return list;
}
public void StoreElement(XElement element, string friendlyName)
{
var xml = element.ToString(SaveOptions.DisableFormatting);
/* 1) write to Redis (set-add = idempotent) */
_db.SetAdd(_prefix, xml);
}
}

View File

@@ -0,0 +1,26 @@
###### generated-by: Ablera.Serdica.CiJobsBuilder 1.0.0 ######
###### Build & Publish ########################################################
FROM mirrors.ablera.dev/docker-mirror/dotnet/sdk:9.0-alpine AS build
WORKDIR /
COPY . .
WORKDIR /src/Serdica/Ablera.Serdica.Authority/Ablera.Serdica.Authority
RUN dotnet restore "Ablera.Serdica.Authority.csproj"
RUN dotnet publish "Ablera.Serdica.Authority.csproj" -c Release -o /app/publish
###### Run stage ##############################################################
FROM mirrors.ablera.dev/docker-mirror/dotnet/aspnet:9.0-alpine AS final
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV TZ=UTC
RUN apk add --no-cache curl icu-data-full icu-libs tzdata
WORKDIR /app
COPY --from=build /app/publish .
CMD ["dotnet","Ablera.Serdica.Authority.dll"]
# port should match a port the web server is listening on
ENV HEALTHCHECK_PORT=80 \
HEALTHCHECK_HOST=localhost \
HEALTHCHECK_PROTOCOL=http \
HEALTHCHECK_ENDPOINT="health"
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sSLf ${HEALTHCHECK_PROTOCOL}://${HEALTHCHECK_HOST}:${HEALTHCHECK_PORT}/${HEALTHCHECK_ENDPOINT} || (echo 'Health check failed!' && exit 1)

View File

@@ -0,0 +1,501 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.0.11012.119 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authority", "Ablera.Serdica.Authority\Ablera.Serdica.Authority.csproj", "{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Common.Tools", "..\..\__Libraries\Ablera.Serdica.Common.Tools\Ablera.Serdica.Common.Tools.csproj", "{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Common.Services", "..\..\__Libraries\Ablera.Serdica.Common.Services\Ablera.Serdica.Common.Services.csproj", "{2C117C87-F749-88D4-F947-0C3165F99365}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Microservice.Initializer", "..\..\__Libraries\Ablera.Serdica.Microservice.Initializer\Ablera.Serdica.Microservice.Initializer.csproj", "{56D0F1F5-8658-A87B-3E10-1E6674B39943}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Microservice.Initializer.EndpointsRegistration", "..\..\__Libraries\Ablera.Serdica.Microservice.Initializer.EndpointsRegistration\Ablera.Serdica.Microservice.Initializer.EndpointsRegistration.csproj", "{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Microservice.Consumer", "..\..\__Libraries\Ablera.Serdica.Microservice.Consumer\Ablera.Serdica.Microservice.Consumer.csproj", "{58186FA9-D464-8D16-9999-4E747B59C02C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Common.Services.FromEntityFramework", "..\..\__Libraries\Ablera.Serdica.Common.Services.FromEntityFramework\Ablera.Serdica.Common.Services.FromEntityFramework.csproj", "{A90C6420-7BAD-86FB-D4E9-62528940071F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Extensions.RabbitMQ", "..\..\__Libraries\Ablera.Serdica.Extensions.RabbitMQ\Ablera.Serdica.Extensions.RabbitMQ.csproj", "{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Extensions.NJsonSchema", "..\..\__Libraries\Ablera.Serdica.Extensions.NJsonSchema\Ablera.Serdica.Extensions.NJsonSchema.csproj", "{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Extensions.Serilog", "..\..\__Libraries\Ablera.Serdica.Extensions.Serilog\Ablera.Serdica.Extensions.Serilog.csproj", "{163970E8-D955-4963-9B44-F3E576782FE6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.DbConfig", "..\..\__Libraries\Ablera.Serdica.DbConfig\Ablera.Serdica.DbConfig.csproj", "{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Plugin", "..\..\__Libraries\Ablera.Serdica.Plugin\Ablera.Serdica.Plugin.csproj", "{78370B69-97D0-AAB0-FBF4-97A4757563B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.DBModels.Serdica", "..\..\__Libraries\Ablera.Serdica.DBModels.Serdica\Ablera.Serdica.DBModels.Serdica.csproj", "{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.LocalCacheProvider", "..\..\__Libraries\Ablera.Serdica.LocalCacheProvider\Ablera.Serdica.LocalCacheProvider.csproj", "{55832819-3500-D8BA-9EBB-E3E2AB15090B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authentication", "..\..\__Libraries\Ablera.Serdica.Authentication\Ablera.Serdica.Authentication.csproj", "{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.TranslationProvider", "..\..\__Libraries\Ablera.Serdica.TranslationProvider\Ablera.Serdica.TranslationProvider.csproj", "{B22FADB1-C377-F072-0419-E15D363A64AD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
Dockerfile = Dockerfile
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Plugins", "__Plugins", "{D8B47378-81A7-4BE3-8B76-B48D01E4D704}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authority.Plugin.Standard", "__Plugins\Ablera.Serdica.Authority.Plugin.Standard\Ablera.Serdica.Authority.Plugin.Standard.csproj", "{36E54ACD-38EF-8350-82B7-2DBF372C5239}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.DBModels.Oidc", "__Libraries\Ablera.Serdica.DBModels.Oidc\Ablera.Serdica.DBModels.Oidc.csproj", "{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Extensions.Redis", "..\..\__Libraries\Ablera.Serdica.Extensions.Redis\Ablera.Serdica.Extensions.Redis.csproj", "{893C26DF-A9F4-5896-C765-B680DA63D23C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.DBModels.Oidc.Migrations", "__Libraries\Ablera.Serdica.DBModels.Oidc.Migrations\Ablera.Serdica.DBModels.Oidc.Migrations.csproj", "{2572437D-2AA9-A956-3EA7-2DD09105AFC1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authorization", "..\..\__Libraries\Ablera.Serdica.Authorization\Ablera.Serdica.Authorization.csproj", "{387A2480-D7FB-6F9D-6D93-F96970DAB46B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Extensions.MessagePack", "..\..\__Libraries\Ablera.Serdica.Extensions.MessagePack\Ablera.Serdica.Extensions.MessagePack.csproj", "{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.UserConfiguration", "..\..\__Libraries\Ablera.Serdica.UserConfiguration\Ablera.Serdica.UserConfiguration.csproj", "{4E4CAE4A-E577-174F-9671-EBB759F44E77}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.UserConfiguration.Redis", "..\..\__Libraries\Ablera.Serdica.UserConfiguration.Redis\Ablera.Serdica.UserConfiguration.Redis.csproj", "{29B145E2-F37C-A614-F834-7F1F484ED142}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.UserConfiguration.Builder", "..\..\__Libraries\Ablera.Serdica.UserConfiguration.Builder\Ablera.Serdica.UserConfiguration.Builder.csproj", "{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{6517AF15-46A7-4D81-A060-20FD1785EDE6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Extensions.Novell.Directory.Ldap", "..\..\__Libraries\Ablera.Serdica.Extensions.Novell.Directory.Ldap\Ablera.Serdica.Extensions.Novell.Directory.Ldap.csproj", "{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authority.Plugins.Base", "__Plugins\Ablera.Serdica.Authority.Plugins.Base\Ablera.Serdica.Authority.Plugins.Base.csproj", "{2804361B-83DD-DD87-ED76-3DAF19778DC5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authority.Plugins.LdapUtilities", "__Plugins\Ablera.Serdica.Authority.Plugins.LdapUtilities\Ablera.Serdica.Authority.Plugins.LdapUtilities.csproj", "{225906DB-8525-9CF4-EE0D-1996AF58A7AE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.HealthChecks", "..\..\__Libraries\Ablera.Serdica.HealthChecks\Ablera.Serdica.HealthChecks.csproj", "{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authority.Plugin.Bulstrad", "__Plugins\Ablera.Serdica.Authority.Plugin.Bulstrad\Ablera.Serdica.Authority.Plugin.Bulstrad.csproj", "{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ablera.Serdica.Authority.Plugin.Ldap", "__Plugins\Ablera.Serdica.Authority.Plugin.Ldap\Ablera.Serdica.Authority.Plugin.Ldap.csproj", "{20476940-0B2C-62FE-F772-7E8C77D24A9B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Debug|x64.ActiveCfg = Debug|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Debug|x64.Build.0 = Debug|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Debug|x86.ActiveCfg = Debug|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Debug|x86.Build.0 = Debug|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Release|Any CPU.Build.0 = Release|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Release|x64.ActiveCfg = Release|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Release|x64.Build.0 = Release|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Release|x86.ActiveCfg = Release|Any CPU
{4DC6FDAD-3F58-662F-B66C-35BD90B3300B}.Release|x86.Build.0 = Release|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Debug|x64.ActiveCfg = Debug|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Debug|x64.Build.0 = Debug|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Debug|x86.ActiveCfg = Debug|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Debug|x86.Build.0 = Debug|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Release|Any CPU.Build.0 = Release|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Release|x64.ActiveCfg = Release|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Release|x64.Build.0 = Release|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Release|x86.ActiveCfg = Release|Any CPU
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C}.Release|x86.Build.0 = Release|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Debug|x64.ActiveCfg = Debug|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Debug|x64.Build.0 = Debug|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Debug|x86.ActiveCfg = Debug|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Debug|x86.Build.0 = Debug|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Release|Any CPU.Build.0 = Release|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Release|x64.ActiveCfg = Release|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Release|x64.Build.0 = Release|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Release|x86.ActiveCfg = Release|Any CPU
{2C117C87-F749-88D4-F947-0C3165F99365}.Release|x86.Build.0 = Release|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Debug|Any CPU.Build.0 = Debug|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Debug|x64.ActiveCfg = Debug|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Debug|x64.Build.0 = Debug|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Debug|x86.ActiveCfg = Debug|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Debug|x86.Build.0 = Debug|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Release|Any CPU.ActiveCfg = Release|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Release|Any CPU.Build.0 = Release|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Release|x64.ActiveCfg = Release|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Release|x64.Build.0 = Release|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Release|x86.ActiveCfg = Release|Any CPU
{56D0F1F5-8658-A87B-3E10-1E6674B39943}.Release|x86.Build.0 = Release|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Debug|x64.ActiveCfg = Debug|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Debug|x64.Build.0 = Debug|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Debug|x86.ActiveCfg = Debug|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Debug|x86.Build.0 = Debug|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Release|Any CPU.Build.0 = Release|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Release|x64.ActiveCfg = Release|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Release|x64.Build.0 = Release|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Release|x86.ActiveCfg = Release|Any CPU
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C}.Release|x86.Build.0 = Release|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Debug|x64.ActiveCfg = Debug|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Debug|x64.Build.0 = Debug|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Debug|x86.ActiveCfg = Debug|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Debug|x86.Build.0 = Debug|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Release|Any CPU.Build.0 = Release|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Release|x64.ActiveCfg = Release|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Release|x64.Build.0 = Release|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Release|x86.ActiveCfg = Release|Any CPU
{58186FA9-D464-8D16-9999-4E747B59C02C}.Release|x86.Build.0 = Release|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Debug|x64.ActiveCfg = Debug|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Debug|x64.Build.0 = Debug|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Debug|x86.ActiveCfg = Debug|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Debug|x86.Build.0 = Debug|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Release|Any CPU.Build.0 = Release|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Release|x64.ActiveCfg = Release|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Release|x64.Build.0 = Release|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Release|x86.ActiveCfg = Release|Any CPU
{A90C6420-7BAD-86FB-D4E9-62528940071F}.Release|x86.Build.0 = Release|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Debug|x64.ActiveCfg = Debug|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Debug|x64.Build.0 = Debug|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Debug|x86.ActiveCfg = Debug|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Debug|x86.Build.0 = Debug|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Release|Any CPU.Build.0 = Release|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Release|x64.ActiveCfg = Release|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Release|x64.Build.0 = Release|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Release|x86.ActiveCfg = Release|Any CPU
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4}.Release|x86.Build.0 = Release|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Debug|x64.ActiveCfg = Debug|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Debug|x64.Build.0 = Debug|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Debug|x86.ActiveCfg = Debug|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Debug|x86.Build.0 = Debug|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Release|Any CPU.Build.0 = Release|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Release|x64.ActiveCfg = Release|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Release|x64.Build.0 = Release|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Release|x86.ActiveCfg = Release|Any CPU
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322}.Release|x86.Build.0 = Release|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Debug|x64.ActiveCfg = Debug|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Debug|x64.Build.0 = Debug|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Debug|x86.ActiveCfg = Debug|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Debug|x86.Build.0 = Debug|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Release|Any CPU.Build.0 = Release|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Release|x64.ActiveCfg = Release|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Release|x64.Build.0 = Release|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Release|x86.ActiveCfg = Release|Any CPU
{163970E8-D955-4963-9B44-F3E576782FE6}.Release|x86.Build.0 = Release|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Debug|x64.ActiveCfg = Debug|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Debug|x64.Build.0 = Debug|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Debug|x86.ActiveCfg = Debug|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Debug|x86.Build.0 = Debug|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Release|Any CPU.Build.0 = Release|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Release|x64.ActiveCfg = Release|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Release|x64.Build.0 = Release|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Release|x86.ActiveCfg = Release|Any CPU
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8}.Release|x86.Build.0 = Release|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Debug|x64.ActiveCfg = Debug|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Debug|x64.Build.0 = Debug|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Debug|x86.ActiveCfg = Debug|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Debug|x86.Build.0 = Debug|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Release|Any CPU.Build.0 = Release|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Release|x64.ActiveCfg = Release|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Release|x64.Build.0 = Release|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Release|x86.ActiveCfg = Release|Any CPU
{78370B69-97D0-AAB0-FBF4-97A4757563B6}.Release|x86.Build.0 = Release|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Debug|x64.ActiveCfg = Debug|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Debug|x64.Build.0 = Debug|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Debug|x86.ActiveCfg = Debug|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Debug|x86.Build.0 = Debug|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Release|Any CPU.Build.0 = Release|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Release|x64.ActiveCfg = Release|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Release|x64.Build.0 = Release|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Release|x86.ActiveCfg = Release|Any CPU
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0}.Release|x86.Build.0 = Release|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Debug|x64.ActiveCfg = Debug|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Debug|x64.Build.0 = Debug|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Debug|x86.ActiveCfg = Debug|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Debug|x86.Build.0 = Debug|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Release|Any CPU.Build.0 = Release|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Release|x64.ActiveCfg = Release|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Release|x64.Build.0 = Release|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Release|x86.ActiveCfg = Release|Any CPU
{55832819-3500-D8BA-9EBB-E3E2AB15090B}.Release|x86.Build.0 = Release|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Debug|x64.ActiveCfg = Debug|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Debug|x64.Build.0 = Debug|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Debug|x86.ActiveCfg = Debug|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Debug|x86.Build.0 = Debug|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Release|Any CPU.Build.0 = Release|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Release|x64.ActiveCfg = Release|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Release|x64.Build.0 = Release|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Release|x86.ActiveCfg = Release|Any CPU
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00}.Release|x86.Build.0 = Release|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Debug|x64.ActiveCfg = Debug|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Debug|x64.Build.0 = Debug|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Debug|x86.ActiveCfg = Debug|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Debug|x86.Build.0 = Debug|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Release|Any CPU.Build.0 = Release|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Release|x64.ActiveCfg = Release|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Release|x64.Build.0 = Release|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Release|x86.ActiveCfg = Release|Any CPU
{B22FADB1-C377-F072-0419-E15D363A64AD}.Release|x86.Build.0 = Release|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Debug|Any CPU.Build.0 = Debug|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Debug|x64.ActiveCfg = Debug|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Debug|x64.Build.0 = Debug|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Debug|x86.ActiveCfg = Debug|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Debug|x86.Build.0 = Debug|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Release|Any CPU.ActiveCfg = Release|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Release|Any CPU.Build.0 = Release|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Release|x64.ActiveCfg = Release|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Release|x64.Build.0 = Release|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Release|x86.ActiveCfg = Release|Any CPU
{36E54ACD-38EF-8350-82B7-2DBF372C5239}.Release|x86.Build.0 = Release|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Debug|x64.ActiveCfg = Debug|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Debug|x64.Build.0 = Debug|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Debug|x86.ActiveCfg = Debug|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Debug|x86.Build.0 = Debug|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Release|Any CPU.Build.0 = Release|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Release|x64.ActiveCfg = Release|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Release|x64.Build.0 = Release|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Release|x86.ActiveCfg = Release|Any CPU
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B}.Release|x86.Build.0 = Release|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Debug|x64.ActiveCfg = Debug|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Debug|x64.Build.0 = Debug|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Debug|x86.ActiveCfg = Debug|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Debug|x86.Build.0 = Debug|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Release|Any CPU.Build.0 = Release|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Release|x64.ActiveCfg = Release|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Release|x64.Build.0 = Release|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Release|x86.ActiveCfg = Release|Any CPU
{893C26DF-A9F4-5896-C765-B680DA63D23C}.Release|x86.Build.0 = Release|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Debug|x64.ActiveCfg = Debug|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Debug|x64.Build.0 = Debug|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Debug|x86.ActiveCfg = Debug|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Debug|x86.Build.0 = Debug|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Release|Any CPU.Build.0 = Release|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Release|x64.ActiveCfg = Release|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Release|x64.Build.0 = Release|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Release|x86.ActiveCfg = Release|Any CPU
{2572437D-2AA9-A956-3EA7-2DD09105AFC1}.Release|x86.Build.0 = Release|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Debug|x64.ActiveCfg = Debug|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Debug|x64.Build.0 = Debug|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Debug|x86.ActiveCfg = Debug|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Debug|x86.Build.0 = Debug|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Release|Any CPU.Build.0 = Release|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Release|x64.ActiveCfg = Release|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Release|x64.Build.0 = Release|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Release|x86.ActiveCfg = Release|Any CPU
{387A2480-D7FB-6F9D-6D93-F96970DAB46B}.Release|x86.Build.0 = Release|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Debug|x64.ActiveCfg = Debug|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Debug|x64.Build.0 = Debug|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Debug|x86.ActiveCfg = Debug|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Debug|x86.Build.0 = Debug|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Release|Any CPU.Build.0 = Release|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Release|x64.ActiveCfg = Release|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Release|x64.Build.0 = Release|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Release|x86.ActiveCfg = Release|Any CPU
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4}.Release|x86.Build.0 = Release|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Debug|x64.ActiveCfg = Debug|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Debug|x64.Build.0 = Debug|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Debug|x86.ActiveCfg = Debug|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Debug|x86.Build.0 = Debug|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Release|Any CPU.Build.0 = Release|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Release|x64.ActiveCfg = Release|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Release|x64.Build.0 = Release|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Release|x86.ActiveCfg = Release|Any CPU
{4E4CAE4A-E577-174F-9671-EBB759F44E77}.Release|x86.Build.0 = Release|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Debug|Any CPU.Build.0 = Debug|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Debug|x64.ActiveCfg = Debug|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Debug|x64.Build.0 = Debug|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Debug|x86.ActiveCfg = Debug|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Debug|x86.Build.0 = Debug|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Release|Any CPU.ActiveCfg = Release|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Release|Any CPU.Build.0 = Release|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Release|x64.ActiveCfg = Release|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Release|x64.Build.0 = Release|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Release|x86.ActiveCfg = Release|Any CPU
{29B145E2-F37C-A614-F834-7F1F484ED142}.Release|x86.Build.0 = Release|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Debug|x64.ActiveCfg = Debug|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Debug|x64.Build.0 = Debug|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Debug|x86.ActiveCfg = Debug|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Debug|x86.Build.0 = Debug|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Release|Any CPU.Build.0 = Release|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Release|x64.ActiveCfg = Release|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Release|x64.Build.0 = Release|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Release|x86.ActiveCfg = Release|Any CPU
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0}.Release|x86.Build.0 = Release|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Debug|x64.ActiveCfg = Debug|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Debug|x64.Build.0 = Debug|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Debug|x86.ActiveCfg = Debug|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Debug|x86.Build.0 = Debug|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Release|Any CPU.Build.0 = Release|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Release|x64.ActiveCfg = Release|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Release|x64.Build.0 = Release|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Release|x86.ActiveCfg = Release|Any CPU
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB}.Release|x86.Build.0 = Release|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Debug|x64.ActiveCfg = Debug|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Debug|x64.Build.0 = Debug|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Debug|x86.ActiveCfg = Debug|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Debug|x86.Build.0 = Debug|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Release|Any CPU.Build.0 = Release|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Release|x64.ActiveCfg = Release|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Release|x64.Build.0 = Release|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Release|x86.ActiveCfg = Release|Any CPU
{2804361B-83DD-DD87-ED76-3DAF19778DC5}.Release|x86.Build.0 = Release|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Debug|x64.ActiveCfg = Debug|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Debug|x64.Build.0 = Debug|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Debug|x86.ActiveCfg = Debug|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Debug|x86.Build.0 = Debug|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Release|Any CPU.Build.0 = Release|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Release|x64.ActiveCfg = Release|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Release|x64.Build.0 = Release|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Release|x86.ActiveCfg = Release|Any CPU
{225906DB-8525-9CF4-EE0D-1996AF58A7AE}.Release|x86.Build.0 = Release|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Debug|x64.ActiveCfg = Debug|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Debug|x64.Build.0 = Debug|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Debug|x86.ActiveCfg = Debug|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Debug|x86.Build.0 = Debug|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Release|Any CPU.Build.0 = Release|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Release|x64.ActiveCfg = Release|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Release|x64.Build.0 = Release|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Release|x86.ActiveCfg = Release|Any CPU
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A}.Release|x86.Build.0 = Release|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Debug|x64.ActiveCfg = Debug|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Debug|x64.Build.0 = Debug|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Debug|x86.ActiveCfg = Debug|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Debug|x86.Build.0 = Debug|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Release|Any CPU.Build.0 = Release|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Release|x64.ActiveCfg = Release|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Release|x64.Build.0 = Release|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Release|x86.ActiveCfg = Release|Any CPU
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C}.Release|x86.Build.0 = Release|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Debug|x64.ActiveCfg = Debug|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Debug|x64.Build.0 = Debug|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Debug|x86.ActiveCfg = Debug|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Debug|x86.Build.0 = Debug|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Release|Any CPU.Build.0 = Release|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Release|x64.ActiveCfg = Release|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Release|x64.Build.0 = Release|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Release|x86.ActiveCfg = Release|Any CPU
{20476940-0B2C-62FE-F772-7E8C77D24A9B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{AB637A9A-1ED1-27BC-5FC7-84775EC61C9C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{2C117C87-F749-88D4-F947-0C3165F99365} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{56D0F1F5-8658-A87B-3E10-1E6674B39943} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{1E2B3B33-C1C9-A86C-234D-8E3D2487381C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{58186FA9-D464-8D16-9999-4E747B59C02C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{A90C6420-7BAD-86FB-D4E9-62528940071F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{3D860D17-A14E-25AE-81A0-DB0D0EBBEAD4} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{C0692A9A-9841-F95A-A07B-0C0AC6AA1322} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{163970E8-D955-4963-9B44-F3E576782FE6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{5BC0A7B5-5CD7-572F-BBC0-01AA8C62CDE8} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{78370B69-97D0-AAB0-FBF4-97A4757563B6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{22036806-8B3D-67C6-2CE7-8F4D7E192BB0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{55832819-3500-D8BA-9EBB-E3E2AB15090B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{FCBDFBDE-E76B-964D-24E8-9F01F69D1A00} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{B22FADB1-C377-F072-0419-E15D363A64AD} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{36E54ACD-38EF-8350-82B7-2DBF372C5239} = {D8B47378-81A7-4BE3-8B76-B48D01E4D704}
{0AB994AF-7DE0-B08D-6428-1EA9AEF3DE0B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{893C26DF-A9F4-5896-C765-B680DA63D23C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{2572437D-2AA9-A956-3EA7-2DD09105AFC1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{387A2480-D7FB-6F9D-6D93-F96970DAB46B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{FEE40D33-2AB0-2891-706F-4BE662BD2CF4} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{4E4CAE4A-E577-174F-9671-EBB759F44E77} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{29B145E2-F37C-A614-F834-7F1F484ED142} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{3DD8C0FB-7500-2F44-8C5B-A6DAF54C27F0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{6517AF15-46A7-4D81-A060-20FD1785EDE6} = {D8B47378-81A7-4BE3-8B76-B48D01E4D704}
{E2C3643E-C60F-4BB8-A7EA-12CB038346FB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{2804361B-83DD-DD87-ED76-3DAF19778DC5} = {6517AF15-46A7-4D81-A060-20FD1785EDE6}
{225906DB-8525-9CF4-EE0D-1996AF58A7AE} = {6517AF15-46A7-4D81-A060-20FD1785EDE6}
{E3905D64-D056-4EF3-B4C9-98A4EEB7E71A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{DBE3EF10-21FE-9F9B-E292-DD6D4E22192C} = {D8B47378-81A7-4BE3-8B76-B48D01E4D704}
{20476940-0B2C-62FE-F772-7E8C77D24A9B} = {D8B47378-81A7-4BE3-8B76-B48D01E4D704}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F7F3E93C-1A9C-4268-867E-2179FA05A877}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,52 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>1.0.0</Version>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<EnableDefaultContentItems>false</EnableDefaultContentItems>
</PropertyGroup>
<ItemGroup>
<None Include="NuGet.config" />
<Folder Include="wwwroot\" />
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="oidc-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="wwwroot\login.html" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.5" />
<PackageReference Include="OpenIddict.Quartz" Version="6.3.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="6.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Authorization/Ablera.Serdica.Authorization.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Common.Tools/Ablera.Serdica.Common.Tools.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.DbConfig/Ablera.Serdica.DbConfig.csproj" />
<ProjectReference Include="../__Libraries/Ablera.Serdica.DBModels.Oidc.Migrations/Ablera.Serdica.DBModels.Oidc.Migrations.csproj" />
<ProjectReference Include="../__Libraries/Ablera.Serdica.DBModels.Oidc/Ablera.Serdica.DBModels.Oidc.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.DBModels.Serdica/Ablera.Serdica.DBModels.Serdica.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Extensions.Redis/Ablera.Serdica.Extensions.Redis.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Extensions.Serilog/Ablera.Serdica.Extensions.Serilog.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.HealthChecks/Ablera.Serdica.HealthChecks.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Microservice.Consumer/Ablera.Serdica.Microservice.Consumer.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Microservice.Initializer.EndpointsRegistration/Ablera.Serdica.Microservice.Initializer.EndpointsRegistration.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Plugin/Ablera.Serdica.Plugin.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.Common.Services.FromEntityFramework/Ablera.Serdica.Common.Services.FromEntityFramework.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.TranslationProvider/Ablera.Serdica.TranslationProvider.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.UserConfiguration.Builder/Ablera.Serdica.UserConfiguration.Builder.csproj" />
<ProjectReference Include="../../../__Libraries/Ablera.Serdica.UserConfiguration.Redis/Ablera.Serdica.UserConfiguration.Redis.csproj" />
<ProjectReference Include="..\__Plugins\Ablera.Serdica.Authority.Plugins.Base\Ablera.Serdica.Authority.Plugins.Base.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using NJsonSchema.Annotations;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Constants;
public class ConstantsClass
{
public const string ConnectionNameDefault = "DefaultConnection";
public const string AuthenticationDelegateUrlKey = "authenticationDelegateUrl";
public const string SignOutUrlKey = "signOutUrl";
public const string YesKey = "Y";
public const string NoKey = "N";
}

View File

@@ -0,0 +1,6 @@
namespace Ablera.Serdica.Authority.Constants;
public static class MessageKeys
{
public static string FailedToChangePassword = nameof(FailedToChangePassword);
}

View File

@@ -0,0 +1,8 @@
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
namespace Ablera.Serdica.Authority.Contracts;
public interface IUserManagingDirector<TUser>
: IUserManagementFacade<TUser>
where TUser : class
{
}

View File

@@ -0,0 +1,130 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.Authentication.Models;
using System;
using System.ComponentModel.DataAnnotations;
using Ablera.Serdica.DBModels.Serdica;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Ablera.Serdica.Common.Services.Contracts;
using Ablera.Serdica.UserConfiguration.Models;
using Ablera.Serdica.UserConfiguration.Contracts;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
namespace Ablera.Serdica.NotificationService.Endpoints;
public class UpdateUsersConfigurationRequest
{
public string? UserGuid { get; set; }
public string? Language { get; set; }
public string? Country { get; set; }
public long? AutoLogoutMinutes { get; set; }
public string? BranchCode { get; set; }
}
[Command("users_update_user_configuration", timeoutInSeconds: 10, allowedRoles: [SerdicaClaims.IsAuthenticated])]
public class UpdateUserConfigurationEndpoint(
SerdicaDbContext dbContext,
IEnvironment environment,
IEndpointRequestMessageProvider requestMessageProvider,
IUserConfigurationRepository<UserConfigurationModel> userConfigurationRepository,
ILogger<UpdateUserConfigurationEndpoint> logger)
: IEndpointWithRequest<UpdateUsersConfigurationRequest, UserConfigurationModel?>
{
public async Task<UserConfigurationModel?> ConsumeAsync(UpdateUsersConfigurationRequest request)
{
if (request.UserGuid != null && request.UserGuid != requestMessageProvider.RequestMessage.UserId)
{
var isLoggedInUserSuperUser = await dbContext.UserAccounts
.Where(x => x.UserGuid == request.UserGuid)
.SelectMany(x => x.UserRole1s)
.Select(x => x.Id)
.AnyAsync(x => x == SerdicaClaims.RoleSuperUser);
if (request.UserGuid != requestMessageProvider.RequestMessage.UserId && isLoggedInUserSuperUser != true)
{
throw new BaseResultException("not_authorized".AsCode(), "You are not authorized to change configuration for this user!");
}
}
request.UserGuid ??= requestMessageProvider.RequestMessage.UserId;
logger.LogInformation("Attempting to update user profile for: '{userGuid}'.", request.UserGuid);
using var tx = dbContext.Database.BeginTransaction();
UserAccount? userAccount = null;
try
{
userAccount = await dbContext.UserAccounts
.Where(u => u.UserGuid == request.UserGuid)
.FirstOrDefaultAsync();
if (userAccount == null)
{
const string errorMsg =
"User identifier from a token does not match a user in the database: '{userGuid}'.";
logger.LogError(errorMsg, request.UserGuid);
throw new Exception(errorMsg.Replace("{userGuid}", request.UserGuid));
}
if (request.Language != null)
{
userAccount.Language = request.Language;
}
if (request.Country != null)
{
userAccount.Country = request.Country;
}
if (request.AutoLogoutMinutes.HasValue)
{
userAccount.AutoLogoutMinutes = request.AutoLogoutMinutes switch
{
null => environment.DefaultAutoLogoutInMinutes, // Use default when not provided
var minutes when minutes > environment.MaximumAutoLogoutInMinutes => throw new BaseResultException(
$"The auto logout minutes value cannot be greater than the maximum allowed: {environment.MaximumAutoLogoutInMinutes}"),
var minutes when minutes < environment.MinimumAutoLogoutInMinutes => throw new BaseResultException(
$"The auto logout minutes value cannot be less than the minimum allowed: {environment.MinimumAutoLogoutInMinutes}"),
var minutes => minutes // Otherwise, use the provided value
};
}
if (request.BranchCode != null)
{
await dbContext.UserAccounts
.Where(ua => ua.UserAccountId == userAccount.UserAccountId)
.ExecuteUpdateAsync(ua =>
ua.SetProperty(
prop => prop.CurrentBranch,
dbContext.IcUsers
.Include(icUser => icUser.IcBranch)
.Where(icUser => icUser.UserAccountId == userAccount.UserAccountId
&& icUser.IcBranch.BranchCode == request.BranchCode)
.Select(icUserId => icUserId.IcUserId)
.FirstOrDefault()
)
);
}
await dbContext.SaveChangesAsync();
await tx.CommitAsync();
logger.LogInformation("Successfully updated settings for user: '{username}'.", userAccount.UserName);
}
catch (Exception ex)
{
await tx.RollbackAsync();
logger.LogError(
ex,
"Failed to update user profile for: '{userGuid}'.",
request.UserGuid);
throw;
}
// Rebuild the configuration to include the updates roles
var userConfiguration = await userConfigurationRepository.RetrieveAsync(userAccount.UserGuid, true);
return userConfiguration;
}
}

View File

@@ -0,0 +1,42 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.DBModels.Serdica;
using System.Linq;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
using Microsoft.EntityFrameworkCore;
using System;
using Ablera.Serdica.Microservice.Consumer.Contracts;
using static Ablera.Serdica.Authority.Constants.ConstantsClass;
namespace Ablera.Serdica.NotificationService.Endpoints;
public record UserSetEnabledLoginRequest
{
public required string UserGuid { get; init; }
public required bool LoginEnabled { get; init; }
}
public record UserSetEnabledLoginResponse
{
public required bool Updated { get; init; }
}
[Command("users_update_user_login_enabled", timeoutInSeconds: 10, methodName: "POST", allowedRoles: [SerdicaClaims.RoleSuperUser])]
public class UpdateUserLoginEnabledEndpoint(
SerdicaDbContext dbContext)
: Microservice.Consumer.Contracts.Asynchronous.IEndpointWithRequest<UserSetEnabledLoginRequest, UserSetEnabledLoginResponse>
{
public async Task<UserSetEnabledLoginResponse> ConsumeAsync(UserSetEnabledLoginRequest request)
{
var updated = await dbContext.UserAccounts
.Where(x => x.UserGuid == request.UserGuid)
.ExecuteUpdateAsync(x => x.SetProperty(y => y.LockAccount, request.LoginEnabled ? NoKey : YesKey));
return new UserSetEnabledLoginResponse { Updated = updated > 0 };
}
}

View File

@@ -0,0 +1,69 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.DBModels.Serdica;
using System.Linq;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
using Microsoft.EntityFrameworkCore;
using System;
using Ablera.Serdica.Microservice.Consumer.Contracts;
using static Ablera.Serdica.Authority.Constants.ConstantsClass;
using Ablera.Serdica.Authority.Contracts;
using Microsoft.AspNetCore.Identity;
using Ablera.Serdica.Authority.Constants;
namespace Ablera.Serdica.NotificationService.Endpoints;
public record UserChangePasswordRequest
{
public required string? UserGuid { get; set; }
public required string Password { get; init; }
public required string ConfirmedPassword { get; init; }
}
public record UserChangePasswordResponse
{
public required bool Succeeded { get; init; }
}
[Command("users_update_user_password", timeoutInSeconds: 10, methodName: "POST", allowedRoles: [SerdicaClaims.IsAuthenticated])]
public class UpdateUserPasswordEndpoint(
IEndpointRequestMessageProvider requestMessageProvider,
IUserManagingDirector<IdentityUser<string>> users,
SerdicaDbContext dbContext)
: Microservice.Consumer.Contracts.Asynchronous.IEndpointWithRequest<UserChangePasswordRequest, UserChangePasswordResponse>
{
public async Task<UserChangePasswordResponse> ConsumeAsync(UserChangePasswordRequest request)
{
if (request.UserGuid != null && request.UserGuid != requestMessageProvider.RequestMessage.UserId)
{
var isLoggedInUserSuperUser = await dbContext.UserAccounts
.Where(x => x.UserGuid == request.UserGuid)
.SelectMany(x => x.UserRole1s)
.Select(x => x.Id)
.AnyAsync(x => x == SerdicaClaims.RoleSuperUser);
if (request.UserGuid != requestMessageProvider.RequestMessage.UserId && isLoggedInUserSuperUser != true)
{
throw new BaseResultException("not_authorized".AsCode(), "You are not authorized to change configuration for this user!");
}
}
request.UserGuid ??= requestMessageProvider.RequestMessage.UserId;
var identityUser = await users.FindByIdAsync(request.UserGuid)
?? throw new BaseResultException("account_not_found".AsCode(), "Account associated with the session not found!");
var result = await users.ChangePasswordAsync(identityUser, request.Password, request.ConfirmedPassword);
if (result.Succeeded == false)
{
throw new BaseResultException(
(result.ErrorCode ?? "change_password_failed").AsCode(),
MessageKeys.FailedToChangePassword);
}
return new UserChangePasswordResponse { Succeeded = true };
}
}

View File

@@ -0,0 +1,80 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.Authentication.Models;
using System;
using System.ComponentModel.DataAnnotations;
using Ablera.Serdica.DBModels.Serdica;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Ablera.Serdica.UserConfiguration.Models;
using Ablera.Serdica.Common.Services.Contracts;
using Ablera.Serdica.UserConfiguration.Contracts;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
namespace Ablera.Serdica.NotificationService.Endpoints;
public record UpdateUserRoles
{
public required string UserGuid { get; set; }
public required string[] Roles { get; init; }
}
[Command("users_update_user_roles", timeoutInSeconds: 10, allowedRoles: [SerdicaClaims.RoleSuperUser])]
public class UpdateUserRolesEndpoint(
SerdicaDbContext dbContext,
ILogger<UpdateUserConfigurationEndpoint> logger,
IUserConfigurationRepository<UserConfigurationModel> userConfigurationRepository)
: IEndpointWithRequest<UpdateUserRoles, UserConfigurationModel?>
{
public async Task<UserConfigurationModel?> ConsumeAsync(UpdateUserRoles request)
{
using var tx = dbContext.Database.BeginTransaction();
try
{
// we are using raw sql because the entityframework generator fails to create entity of table contained only by two columns that are FKs
var userAccountId = await dbContext.UserAccounts
.Where(u => u.UserGuid == request.UserGuid)
.Select(u => u.UserAccountId)
.FirstOrDefaultAsync();
// delete old roles
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM SRD_SYS.USER_ROLES WHERE USER_ACCOUNT_ID = {0} ", userAccountId);
// insert new roles
foreach (var role in request.Roles)
{
await dbContext.Database.ExecuteSqlRawAsync(
"INSERT INTO SRD_SYS.USER_ROLES (USER_ACCOUNT_ID, USER_ROLE) ({0}, {1})", userAccountId, role);
}
await dbContext.SaveChangesAsync();
await tx.CommitAsync();
}
catch (Exception ex)
{
await tx.RollbackAsync();
logger.LogError(
ex,
"Failed to update user roles for: '{userGuid}'.",
request.UserGuid);
throw;
}
logger.LogInformation("Successfully update roles for user with identifier: '{identifier}'.", request.UserGuid);
// Rebuild the configuration to include the updates roles
var userConfiguration = await userConfigurationRepository.RetrieveAsync(request.UserGuid, true);
return userConfiguration;
}
}

View File

@@ -0,0 +1,62 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.DBModels.Serdica;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.Extensions.RabbitMQ.Listeners;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
namespace Ablera.Serdica.NotificationService.Endpoints;
public record UserBranchesResponse
{
public long Id { get; init; }
public long BranchId { get; init; }
public required string BranchCode { get; init; }
public required string BranchName { get; init; }
public long? ReportTo { get; init; }
public string? AgentCode { get; init; }
}
[Command("users_get_user_branches", timeoutInSeconds: 10, methodName: "GET", allowedRoles: [SerdicaClaims.IsAuthenticated])]
public class UserBranchesEndpoint(
IEndpointRequestMessageProvider requestMessageProvider,
SerdicaDbContext dbContext)
: IEndpointWithNoRequest<UserBranchesResponse[]>
{
public async Task<UserBranchesResponse[]> ConsumeAsync()
{
var items = await dbContext
.IcUsers
.Include(x => x.IcBranch)
.Include(x => x.UserAccount)
.Where(x => x.UserAccount.UserGuid == requestMessageProvider.RequestMessage.UserId)
.ToListAsync();
var srCustIds = items.Where(x => x.IcBranch != null)
.Select(x => x.IcBranch.SrCustId)
.ToArray();
var branchNames = await dbContext
.CCusts
.Include(x => x.CCompany)
.Where(x => srCustIds.Contains(x.SrCustId))
.Where(x => x.CCompany != null)
.ToDictionaryAsync(x => x.SrCustId, x => x.CCompany.CompName);
var dtos = items.Select(x => new UserBranchesResponse
{
AgentCode = x.AgentCode,
BranchId = x.IcBranchId.HasValue == false ? 0 : (long)x.IcBranchId,
ReportTo = (long?)x.ReportTo,
BranchCode = x.IcBranch.BranchCode,
BranchName = branchNames.ContainsKey(x.IcBranch.SrCustId ?? 0)
? branchNames[x.IcBranch.SrCustId ?? 0]
: x.IcBranch.BranchCode,
Id = (long)x.IcUserId,
});
return dtos.ToArray();
}
}

View File

@@ -0,0 +1,58 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.DBModels.Serdica;
using System.Linq;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
using Ablera.Serdica.UserConfiguration.Models;
using System;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.UserConfiguration.Contracts;
using static Ablera.Serdica.Authority.Constants.ConstantsClass;
namespace Ablera.Serdica.NotificationService.Endpoints;
public record UserConfigurationResponse
{
public required UserConfigurationModel Configuration { get; init; }
public required SerdicaRoute[] Routes { get; init; }
}
[Command("users_get_user_configuration", timeoutInSeconds: 10, methodName: "GET", allowedRoles: [SerdicaClaims.IsAuthenticated])]
public class UserConfigurationEndpoint(
IEndpointRequestMessageProvider requestMessageProvider,
SerdicaDbContext dbContext,
RoutesTreeProvider routesProvider,
IUserConfigurationRepository<UserConfigurationModel> repository)
: IEndpointWithNoRequest<UserConfigurationResponse?>
{
public async Task<UserConfigurationResponse?> ConsumeAsync()
{
var userAccount = dbContext.UserAccounts.Where(x => x.UserGuid == requestMessageProvider.RequestMessage.UserId)?.FirstOrDefault()
?? throw new BaseResultException("account_not_found".AsCode(), "Account associated with the session not found!");
if (userAccount.LockAccount == YesKey)
{
throw new BaseResultException("account_locked".AsCode(), "Your account is locked. Please contact support.");
}
var userConfiguration = await repository.RetrieveAsync(userAccount.UserGuid);
// Recursively filter the snapshot based on user roles and map to final DTO.
var filteredRoutes = (routesProvider.Tree ?? [])
.Select(route => route.FilterAndMapRoute(userConfiguration.Roles))
.Where(r => r != null)
.Cast<SerdicaRoute>()
.ToList();
return new UserConfigurationResponse
{
Configuration = userConfiguration,
Routes = filteredRoutes?.ToArray() ?? []
};
}
}

View File

@@ -0,0 +1,42 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.DBModels.Serdica;
using System.Linq;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
using Microsoft.EntityFrameworkCore;
using System;
using Ablera.Serdica.Microservice.Consumer.Contracts;
using static Ablera.Serdica.Authority.Constants.ConstantsClass;
namespace Ablera.Serdica.NotificationService.Endpoints;
public record UserGetLoginEnabledRequest
{
public required string UserGuid { get; init; }
}
public record UserGetLoginEnabledResponse
{
public required bool LoginEnabled { get; init; }
}
[Command("users_user_login_enabled", timeoutInSeconds: 10, methodName: "POST", allowedRoles: [SerdicaClaims.RoleSuperUser])]
public class UserLoginEnabledEndpoint(
SerdicaDbContext dbContext)
: Microservice.Consumer.Contracts.Asynchronous.IEndpointWithRequest<UserGetLoginEnabledRequest, UserGetLoginEnabledResponse>
{
public async Task<UserGetLoginEnabledResponse> ConsumeAsync(UserGetLoginEnabledRequest request)
{
var userAccount = await dbContext.UserAccounts
.Where(x => x.UserGuid == request.UserGuid)
.FirstOrDefaultAsync()
?? throw new BaseResultException("account_not_found".AsCode(), "Account associated with the session not found!");
return new UserGetLoginEnabledResponse { LoginEnabled = userAccount.LockAccount != YesKey };
}
}

View File

@@ -0,0 +1,38 @@
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authority.Services;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.DBModels.Serdica;
using System.Linq;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
using Microsoft.EntityFrameworkCore;
namespace Ablera.Serdica.NotificationService.Endpoints;
[Command("users_get_user_roles", timeoutInSeconds: 10, methodName: "GET", allowedRoles: [SerdicaClaims.IsAuthenticated])]
public class UserRolesEndpoint(
IEndpointRequestMessageProvider requestMessageProvider,
SerdicaDbContext dbContext)
: IEndpointWithNoRequest<string[]>
{
public async Task<string[]> ConsumeAsync()
{
var userAccount = dbContext.UserAccounts.Where(x => x.UserGuid == requestMessageProvider.RequestMessage.UserId)?.FirstOrDefault()
?? throw new BaseResultException("account_not_found".AsCode(), "Account associated with the session not found!");
var userRoles = await dbContext.UserAccounts
.Where(x => x.UserAccountId == userAccount.UserAccountId)
.SelectMany(x => x.UserRole1s)
.Select(x => x.Id)
.ToArrayAsync();
if (!userRoles.Any())
{
throw new BaseResultException("account_has_no_roles".AsCode(), "Account has not roles set!");
}
return userRoles;
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Ablera.Serdica.Microservice.Consumer.Attributes;
using Ablera.Serdica.Microservice.Consumer.Contracts.Asynchronous;
using Ablera.Serdica.DBModels.Serdica;
using Ablera.Serdica.Common.Tools.Exceptions;
using Ablera.Serdica.Authorization.Models;
using Ablera.Serdica.LocalCacheProvider.Contracts;
using Ablera.Serdica.Authority.Services;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Authentication.Constants;
using Ablera.Serdica.Extensions.RabbitMQ.Contracts;
using Polly;
using System.Configuration;
namespace Ablera.Serdica.NotificationService.Endpoints;
[Command("users_get_user_views", timeoutInSeconds: 5, methodName: "GET", allowedRoles: [SerdicaClaims.IsAuthenticated])]
public class UserViewsEndpoint(
IEndpointRequestMessageProvider requestMessageProvider,
SerdicaDbContext dbContext,
RoutesTreeProvider routesProvider)
: IEndpointWithNoRequest<SerdicaRoute[]>
{
public async Task<SerdicaRoute[]> ConsumeAsync()
{
var userAccount = dbContext.UserAccounts.Where(x => x.UserGuid == requestMessageProvider.RequestMessage.UserId)?.FirstOrDefault()
?? throw new BaseResultException("account_not_found".AsCode(), "Account associated with the session is not found!");
var userRoles = await dbContext.UserAccounts
.Where(x => x.UserAccountId == userAccount.UserAccountId)
.SelectMany(x => x.UserRole1s)
.Select(x => x.Id)
.ToArrayAsync();
if (!userRoles.Any())
{
throw new BaseResultException("account_has_no_roles".AsCode(), "Account has not roles set!");
}
// Recursively filter the snapshot based on user roles and map to final DTO.
var filteredRoutes = (routesProvider.Tree ?? [])
.Select(route => route.FilterAndMapRoute(userRoles))
.Where(r => r != null)
.Cast<SerdicaRoute>()
.ToArray();
return filteredRoutes;
}
}

View File

@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Text.Json;
namespace Ablera.Serdica.Authority.Extensions;
public class JsonElementEqualityComparer : IEqualityComparer<JsonElement>
{
public static JsonElementEqualityComparer Default = new JsonElementEqualityComparer();
public bool Equals(JsonElement x, JsonElement y)
{
// if theyre not both JSON strings, fall back to raw-text compare
if (x.ValueKind == JsonValueKind.String && y.ValueKind == JsonValueKind.String)
return x.GetString() == y.GetString();
// otherwise, compare their entire JSON text
return x.GetRawText() == y.GetRawText();
}
public int GetHashCode(JsonElement obj)
{
// raw text is the canonical JSON including quotes, so it's stable for hashing
return obj.GetRawText().GetHashCode();
}
}
public static class DictionaryExtensions
{
public static bool DictionaryEquals<TKey, TValue>(
this IDictionary<TKey, TValue> a,
IDictionary<TKey, TValue> b,
IEqualityComparer<TValue>? valueComparer = null)
{
// same reference or both null?
if (ReferenceEquals(a, b)) return true;
// one null or different size?
if (a is null || b is null || a.Count != b.Count) return false;
valueComparer ??= EqualityComparer<TValue>.Default;
foreach (var pair in a)
{
// key missing?
if (!b.TryGetValue(pair.Key, out var bValue))
return false;
// value mismatch?
if (!valueComparer.Equals(pair.Value, bValue))
return false;
}
return true;
}
}

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Text.Json;
using System;
namespace Ablera.Serdica.Authority.Extensions;
public static class ImmutableDictionaryExtensions
{
public static string GetStringOrThrow(this IDictionary<string, JsonElement> dict, string key, string clientId)
{
dict.TryGetValue(key, out var jsonElement);
var s = jsonElement.GetString();
if (string.IsNullOrWhiteSpace(s))
{
throw new InvalidOperationException($"No {key} property is defined for client with id '{clientId}'.");
}
return s;
}
}

View File

@@ -0,0 +1,39 @@
using Ablera.Serdica.Authority.Constants;
using Ablera.Serdica.Authority.Extensions;
using Ablera.Serdica.Authority.Services;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Extensions;
public static class RedirectToLoginHandler
{
public static async Task HandlerRedirectToLogin(this RedirectContext<CookieAuthenticationOptions> ctx)
{
var oidcSettings = ctx.HttpContext.RequestServices.GetRequiredService<OidcJsonSettingsProvider>()
.Settings;
// only intercept the OIDC authorize endpoint
if (ctx.Request.Path.StartsWithSegments(oidcSettings!.Endpoints.Authorization.EnsureStartsWith("/") ) == false)
{
ctx.Response.Redirect(ctx.RedirectUri); // normal behaviour
}
var appMgr = ctx.HttpContext.RequestServices
.GetRequiredService<IOpenIddictApplicationManager>();
var oidReq = ctx.HttpContext.GetOpenIddictServerRequest();
var app = await appMgr.FindByClientIdAsync(oidReq!.ClientId!);
var props = await appMgr.GetPropertiesAsync(app!);
var delegateUrl = props.GetStringOrThrow(ConstantsClass.AuthenticationDelegateUrlKey, oidReq.ClientId!);
var confirm = $"{ctx.HttpContext.Request.Scheme}://{ctx.HttpContext.Request.Host}{ctx.Request.Path}{ctx.Request.QueryString}";
var redir = delegateUrl + "&confirmUrl=" + Uri.EscapeDataString(confirm);
ctx.Response.Redirect(redir);
}
}

View File

@@ -0,0 +1,39 @@
using System.Security.Claims;
using OpenIddict.Abstractions;
using Ablera.Serdica.Authority.Contracts;
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;
using static OpenIddict.Abstractions.OpenIddictConstants;
using System.Linq;
using OpenIddict.Server.AspNetCore;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.Cookies;
using static Ablera.Serdica.Authentication.Constants.ConstantsClass;
namespace Ablera.Serdica.Authority.Extensions;
public static class SerdicaPrincipalBuilder
{
public static ClaimsPrincipal Build(IEnumerable<Claim> claims, IEnumerable<string> scopes, string authenticationType)
{
var principal =
new ClaimsPrincipal(
new ClaimsIdentity(
claims,
authenticationType,
Claims.Name,
Claims.Role));
principal.SetResources(SerdicaAPIAudience);
principal.SetScopes(scopes);
principal.SetDestinations(c =>
c.Type == Claims.Name ? new[] { Destinations.AccessToken,
Destinations.IdentityToken }
: new[] { Destinations.AccessToken });
return principal;
}
}

View File

@@ -0,0 +1,18 @@
using System;
namespace Ablera.Serdica.Authority.Extensions;
public static class StringExtensions
{
public static string EnsureStartsWith(this string src, string prefix)
{
return src.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? src : prefix + src;
}
public static string AppendPath(this string src, string suffix)
{
var d = src + suffix.EnsureStartsWith("/");
var r = d.TrimStart('/').EnsureStartsWith("/");
return r;
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace Ablera.Serdica.Authority.Extensions;
public static class UriExtensions
{
public static Uri AppendPath(this Uri baseUrlc, string suffix)
{
var d = new Uri(baseUrlc, baseUrlc.AbsolutePath.AppendPath(suffix));
return d;
}
}

View File

@@ -0,0 +1,91 @@
using Ablera.Serdica.DBModels.Oidc;
using Ablera.Serdica.Authority.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
using System.Threading;
using System;
using Microsoft.EntityFrameworkCore;
using Polly;
public class OidcInfrastructureHostedService(
ILogger<OidcInfrastructureHostedService> logger,
IServiceScopeFactory scopeFactory) : IHostedService, IDisposable
{
private readonly TimeSpan _updateInterval = TimeSpan.FromMinutes(2);
private Timer? _timer;
private CancellationTokenSource? _stoppingCts;
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation($"{nameof(OidcInfrastructureHostedService)} service starting...");
// Apply migrations
using var scope = scopeFactory.CreateScope();
using var dbContext = scope.ServiceProvider.GetRequiredService<OidcDbContext>();
await dbContext.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
await dbContext.Database.MigrateAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
});
// Create a CTS that links the ASP.NET shutdown token with our own
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// Schedule the first run immediately
_ = RunOnceAsync(_stoppingCts.Token);
// Then schedule recurring runs. Notice we capture the CTS token.
_timer = new Timer(
_ => _ = RunOnceAsync(_stoppingCts.Token),
state: null,
dueTime: _updateInterval,
period: _updateInterval);
}
private async Task RunOnceAsync(CancellationToken token)
{
try
{
// Honor cancellation right at the top
token.ThrowIfCancellationRequested();
using var scope = scopeFactory.CreateScope();
using var context = scope.ServiceProvider.GetRequiredService<OidcDbContext>();
var sync = scope.ServiceProvider.GetRequiredService<OidcClientSynchronizer>();
// Do the synchronization
await sync.SynchronizeAsync(token);
}
catch (OperationCanceledException)
{
// Expected on shutdown; swallow.
}
catch (Exception ex)
{
logger.LogError(ex, $"Error while synchronizing {nameof(OidcInfrastructureHostedService)}.");
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation($"{nameof(OidcInfrastructureHostedService)} service stopping...");
// Signal cancellation to the RunOnceAsync calls
_stoppingCts?.Cancel();
// Stop the timer from firing any more
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
_stoppingCts?.Dispose();
}
}

View File

@@ -0,0 +1,138 @@
using Ablera.Serdica.DBModels.Serdica;
using Ablera.Serdica.Microservice.Consumer.Services;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Authority.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.HostServices;
public class RoutesTreeBuilderHostedService(
ILogger<RoutesTreeBuilderHostedService> logger,
IServiceScopeFactory scopeFactory,
RoutesTreeProvider routesTreeProvider)
: IHostedService, IDisposable
{
private Timer? _timer;
private readonly TimeSpan _updateInterval = TimeSpan.FromMinutes(2);
public Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation($"{nameof(RoutesTreeBuilderHostedService)} starting...");
// Initial snapshot update.
UpdateSnapshot();
// Set timer to update every 2 minutes.
_timer = new Timer(state => UpdateSnapshot(), null, _updateInterval, _updateInterval);
return Task.CompletedTask;
}
private void UpdateSnapshot()
{
try
{
using var scope = scopeFactory.CreateScope();
using var requestProfiler = scope.ServiceProvider.GetRequiredService<RequestProfiler>();
requestProfiler.BeginStage("RoutesTreeBuilderService.UpdateSnapshot");
using var dbContext = scope.ServiceProvider.GetRequiredService<SerdicaDbContext>();
// Retrieve flat routes with minimal projection.
var flatRoutes = dbContext.Routes.AsNoTracking()
.OrderBy(x => x.SortOrder)
.Select(x => new RouteEntity
{
Id = x.Id,
ParentId = x.ParentId,
ViewConfigId = x.ViewConfigId,
Type = x.Type,
Title = x.Title,
Disabled = x.Disabled,
IsMenuItem = x.IsMenuItem,
IsDashboardItem = x.IsDashboardItem,
Path = x.Path,
SortOrder = x.SortOrder,
Icon = x.Icon,
SvgIcon = x.SvgIcon,
Breadcrumbs = x.Breadcrumbs,
Translate = x.Translate,
ExternalUrl = x.ExternalUrl,
Url = x.Url,
Function = x.Function,
OpenInNewTab = x.OpenInNewTab,
ExactMatch = x.ExactMatch,
ProductCode = x.ProductCode,
ProcessBusinessKey = x.ProcessBusinessKey,
AllowedRoles = string.IsNullOrWhiteSpace(x.AllowedRoles)
? Array.Empty<string>()
: x.AllowedRoles.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
})
.ToList();
// Convert flat list to a tree structure.
var tree = BuildTree(flatRoutes);
// Atomically update the snapshot.
routesTreeProvider.Tree = tree;
requestProfiler.EndStage("RoutesTreeBuilderService.UpdateSnapshot");
logger.LogInformation("Routes snapshot updated with {Count} root nodes and {Branches} branches.", tree.Count, flatRoutes.Count);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update routes snapshot.");
}
}
private static IReadOnlyList<RouteEntity> BuildTree(List<RouteEntity> flatRoutes)
{
var lookup = flatRoutes.ToDictionary(r => r.Id);
var roots = new List<RouteEntity>();
// Build parent/child relationships.
foreach (var route in flatRoutes)
{
if (route.ParentId.HasValue && lookup.TryGetValue(route.ParentId.Value, out var parent))
{
parent.Children.Add(route);
}
else
{
roots.Add(route);
}
}
// Recursively sort children by SortOrder.
void SortTree(List<RouteEntity> routes)
{
routes.Sort((a, b) => (a.SortOrder ?? 0).CompareTo(b.SortOrder));
foreach (var r in routes)
{
if (r.Children.Any())
{
SortTree(r.Children);
}
}
}
SortTree(roots);
return roots;
}
public Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation($"{nameof(RoutesTreeBuilderHostedService)} stopping...");
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
_timer?.Dispose();
}
}

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Models;
public record FileServerConfig
{
public string RootPathPrefixForWWW { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Models;
public static class RouteEntityExtensions
{
public static SerdicaRoute? FilterAndMapRoute(this RouteEntity route, IEnumerable<string> userRoles)
{
// Skip nodes where user is not allowed.
if (!route.AllowedRoles.Intersect(userRoles).Any())
{
return null;
}
var children = route.Children
.Select(child => FilterAndMapRoute(child, userRoles))
.Where(childDto => childDto != null)
.Cast<SerdicaRoute>()
.ToList();
return new SerdicaRoute(
Id: route.Id,
ParentId: route.ParentId,
ViewConfigId: route.ViewConfigId,
Type: route.Type,
Title: route.Title,
Disabled: route.Disabled,
IsMenuItem: route.IsMenuItem,
IsDashboardItem: route.IsDashboardItem,
Path: route.Path,
SortOrder: route.SortOrder,
Icon: route.Icon,
SvgIcon: route.SvgIcon,
Breadcrumbs: route.Breadcrumbs,
Translate: route.Translate,
ExternalUrl: route.ExternalUrl,
Url: route.Url,
Function: route.Function,
OpenInNewTab: route.OpenInNewTab,
ExactMatch: route.ExactMatch,
ProductCode: route.ProductCode,
ProcessBusinessKey: route.ProcessBusinessKey,
AllowedRoles: route.AllowedRoles,
Children: children
);
}
}
public class RouteEntity
{
public Guid Id { get; set; }
public Guid? ParentId { get; set; }
public Guid? ViewConfigId { get; set; }
public string? Type { get; set; }
public string? Title { get; set; }
public string? Disabled { get; set; }
public string? IsMenuItem { get; set; }
public string? IsDashboardItem { get; set; }
public required string Path { get; set; }
public int? SortOrder { get; set; }
public string? Icon { get; set; }
public string? SvgIcon { get; set; }
public string? Breadcrumbs { get; set; }
public string? Translate { get; set; }
public string? ExternalUrl { get; set; }
public string? Url { get; set; }
public string? Function { get; set; }
public string? OpenInNewTab { get; set; }
public string? ExactMatch { get; set; }
public string? ProductCode { get; set; }
public string? ProcessBusinessKey { get; set; }
public required string[] AllowedRoles { get; set; }
// Children collection for building the tree.
public List<RouteEntity> Children { get; set; } = new List<RouteEntity>();
}
public record SerdicaRoute
(
Guid Id,
Guid? ParentId,
Guid? ViewConfigId,
string? Type,
string? Title,
string? Disabled,
string? IsMenuItem,
string? IsDashboardItem,
string? Path,
int? SortOrder,
string? Icon,
string? SvgIcon,
string? Breadcrumbs,
string? Translate,
string? ExternalUrl,
string? Url,
string? Function,
string? OpenInNewTab,
string? ExactMatch,
string? ProductCode,
string? ProcessBusinessKey,
string[]? AllowedRoles,
IReadOnlyList<SerdicaRoute> Children
);

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Models;
public sealed record TokenRequest
{
[FromForm(Name = "grant_type")] public string? GrantType { get; init; }
[FromForm(Name = "username")] public string? Username { get; init; }
[FromForm(Name = "password")] public string? Password { get; init; }
[FromForm(Name = "client_id")] public required string ClientId { get; init; }
[FromForm(Name = "client_secret")] public required string ClientSecret { get; init; }
[FromForm(Name = "scope")] public string? Scope { get; init; }
[FromForm(Name = "refresh_token")] public string? RefreshToken { get; init; }
[FromForm(Name = "code")] public string? Code { get; init; }
[FromForm(Name = "redirect_uri")] public string? RedirectUri { get; init; }
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Models;
public record UserManagingDirectorConfig
{
public bool LoginAnywhere { get; set; } = true;
public bool UpdateEveryWhere { get; set; } = false;
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget-mirror" value="https://mirrors.ablera.dev/nuget/nuget-mirror/v3/index.json" />
<add key="GitlabSerdicaBackend" value="https://gitlab.ablera.dev/api/v4/projects/92/packages/nuget/index.json" />
</packageSources>
<packageSourceCredentials>
<GitlabSerdicaBackend>
<add key="Username" value="gitlab+deploy-token-3" />
<add key="ClearTextPassword" value="osdy7Ec2sVoSJC2Kaxvr" />
</GitlabSerdicaBackend>
</packageSourceCredentials>
</configuration>

View File

@@ -0,0 +1,80 @@
using System;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Extensions;
using Ablera.Serdica.UserConfiguration.Contracts;
using Ablera.Serdica.UserConfiguration.Models;
using Ablera.Serdica.Authority.Constants;
using Ablera.Serdica.Authority.Contracts;
using Ablera.Serdica.Authority.Extensions;
using Ablera.Serdica.Authority.Services;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
namespace Ablera.Serdica.Authority.OpenIddictServerHandlers;
public sealed class AuthorizationRequestHandler(
AuthenticationUrlBuilder authenticationUrlBuilder,
IHttpContextAccessor httpContextAccessor,
IUserManagingDirector<IdentityUser<string>> users,
IUserConfigurationBuilder<UserConfigurationModel> userConfigurationBuilder,
IUserConfigurationRepository<UserConfigurationModel> userConfigurationRepository,
IOpenIddictApplicationManager manager) :
IOpenIddictServerHandler<OpenIddictServerEvents.HandleAuthorizationRequestContext>
{
public async ValueTask HandleAsync(
OpenIddictServerEvents.HandleAuthorizationRequestContext ctx)
{
var request = httpContextAccessor.HttpContext?.GetOpenIddictServerRequest()
?? throw new InvalidOperationException("No OIDC request found.");
var result = await httpContextAccessor.HttpContext.AuthenticateAsync(
CookieAuthenticationDefaults.AuthenticationScheme);
// ------------ local session exists → issue code/token ------------
if (result.Succeeded)
{
var userId = result.Principal.GetUserId();
if (userId == null) return;
var identityUser = await users.FindByIdAsync(userId);
if (identityUser == null) return;
var systemClaims = result.Principal.Claims ?? [];
//var baseClaims = await users.GetBaseClaimsAsync(identityUser) ?? [];
//var roleClaims = await users.GetRolesClaimsAsync(identityUser) ?? [];
//HashSet<Claim> claims = [.. systemClaims, .. baseClaims, .. roleClaims];
var principal = SerdicaPrincipalBuilder.Build(
systemClaims,
request.GetScopes(),
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
ctx.SignIn(principal);
// store user configuration to be reused from microservices
var userConfiguration = await userConfigurationBuilder.BuildUserConfigurationAsync(userId);
await userConfigurationRepository.StoreAsync(userId, userConfiguration);
return;
}
var client = await manager.FindByClientIdAsync(request.ClientId!);
if (client is null) return;
// ------------- no session → choose where to login -----------------
var authenticationUrl = authenticationUrlBuilder.BuildAuthenticationUrl(
request.ClientId!,
(await manager.GetPropertiesAsync(client))
.GetStringOrThrow(ConstantsClass.AuthenticationDelegateUrlKey, request.ClientId!),
httpContextAccessor.HttpContext!.Request);
if (authenticationUrl is null) return;
httpContextAccessor.HttpContext!.Response.Redirect(authenticationUrl);
ctx.HandleRequest();
}
}

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Authority.Contracts;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Authority.Services;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using OpenIddict.Server.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
namespace Ablera.Serdica.Authority.OpenIddictServerHandlers;
public sealed class ClientCredentialsGrantHandler(
OidcJsonSettingsProvider settingsProvider) :
IOpenIddictServerHandler<HandleTokenRequestContext>
{
public async ValueTask HandleAsync(HandleTokenRequestContext ctx)
{
if (!ctx.Request.IsClientCredentialsGrantType())
return;
var registeredClient = settingsProvider.Settings
.RegisteredClients
.FirstOrDefault(x => x.ClientId == ctx.Request.ClientId!);
if (registeredClient == null)
return;
var claims = new List<Claim>
{
// Exactly **one** subject claim the client_id.
new(Claims.Subject, ctx.Request.ClientId!),
// Name related claims
new(ClaimTypes.NameIdentifier, ctx.Request.ClientId!),
new(ClaimTypes.Name, registeredClient.DisplayName)
};
// Any pre-configured claims
claims.AddRange(
from claimTypeAndValue in registeredClient.BuiltinClaims ?? []
select new Claim(claimTypeAndValue.Type, claimTypeAndValue.Value));
// Build a fresh identity to avoid duplicates.
var principal =
new ClaimsPrincipal(
new ClaimsIdentity(
claims,
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
Claims.Name,
Claims.Role));
// Scopes: intersect requested with allowed set.
var scopes = (registeredClient.Permissions ?? [])
.Where(x => x.StartsWith("scp:"))
.Select(x => x.Substring(4))
.Concat(settingsProvider.Settings.Scopes)
.Distinct()
.ToArray();
principal.SetScopes(ctx.Request.GetScopes().Intersect(scopes));
// API audience(s) your APIs expect.
principal.SetResources(Authentication.Constants.ConstantsClass.SerdicaAPIAudience);
principal.SetDestinations(c =>
c.Type == Claims.Name ? new[] { Destinations.AccessToken,
Destinations.IdentityToken }
: new[] { Destinations.AccessToken });
ctx.SignIn(principal);
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using System.Threading.Tasks;
using OpenIddict.Server;
using Microsoft.AspNetCore.Http;
namespace Ablera.Serdica.Authority.OpenIddictServerHandlers;
public sealed class EndSessionHandler(
IHttpContextAccessor accessor//,
//IOpenIddictAuthorizationManager authMgr,
//IOpenIddictTokenManager tokMgr
) :
IOpenIddictServerHandler<OpenIddictServerEvents.HandleEndSessionRequestContext>
{
public async ValueTask HandleAsync(OpenIddictServerEvents.HandleEndSessionRequestContext ctx)
{
// Do not revoke tokens if the request is not a valid end session request.
// User might be logged in on multiple devices, so we only remove the SSO cookie
// 1) authenticate the cookie (if any)
//var principal = (await accessor.HttpContext!
// .AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme))
// ?.Principal;
//// 2) otherwise fall back to the id_token_hint analysed by OpenIddict
//principal ??= ctx.IdentityTokenHintPrincipal;
//// 3) revoke tokens/authorisations that belong to that user
//if (principal is { }) {
// await foreach (var auth in authMgr.ListAsync())
// await authMgr.TryRevokeAsync(auth);
// await foreach (var tok in tokMgr.ListAsync())
// await tokMgr.TryRevokeAsync(tok);
//}
// 4) remove the SSO cookie
await accessor.HttpContext!.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// 5) let OpenIddict produce the normal response (redirect to SPA)
ctx.SignOut();
}
}

View File

@@ -0,0 +1,92 @@
using OpenIddict.Abstractions;
using Ablera.Serdica.Authority.Contracts;
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;
using OpenIddict.Server;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using System;
using Microsoft.AspNetCore.Http;
using System.Linq;
using Ablera.Serdica.Authority.Extensions;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
namespace Ablera.Serdica.Authority.OpenIddictServerHandlers;
public sealed class PasswordGrantHandler(
IUserManagingDirector<IdentityUser<string>> users,
IHttpContextAccessor httpContextAccessor) :
IOpenIddictServerHandler<HandleTokenRequestContext>
{
public async ValueTask HandleAsync(HandleTokenRequestContext ctx)
{
if (!ctx.Request.IsPasswordGrantType())
return; // not our grant → ignore
var username = ctx.Request.Username;
var password = ctx.Request.Password;
if (username is null || password is null)
{
ctx.Reject(
error: Errors.InvalidGrant,
description: "Missing username or password.");
return;
}
// 1) Find user.
var user = await users.FindByEmailAsync(username) ??
await users.FindByNameAsync(username);
if (user is null)
{
ctx.Reject(
error: Errors.InvalidGrant,
description: "Invalid credentials.");
return;
}
// 2) Validate the password.
var auth = await users.AuthenticateAsync(user, password, false);
if (!auth.Succeeded || auth.ClaimsPrincipal is null)
{
ctx.Reject(
error: Errors.InvalidGrant,
description: "Invalid credentials.");
return;
}
var props = new AuthenticationProperties
{
IsPersistent = true
};
var roleClaims = await users.GetRolesClaimsAsync(user);
var baseClaims = await users.GetBaseClaimsAsync(user);
var principal = SerdicaPrincipalBuilder.Build(
[ ..baseClaims, ..(roleClaims ?? [])],
ctx.Request.GetScopes(),
auth.ClaimsPrincipal.Identity!.AuthenticationType!);
// Issue the local session cookie **for the browser**
await httpContextAccessor.HttpContext!.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
props);
// 4) Tell OpenIddict that everything is OK.
ctx.SignIn(principal);
// ------------------------------------------------------------------
var confirmUrl = httpContextAccessor.HttpContext?.Request?.Query.TryGetValue("confirmUrl", out var values) == true
? values.FirstOrDefault()
: null;
if (string.IsNullOrEmpty(confirmUrl)) return;
httpContextAccessor.HttpContext!.Response.Redirect(Uri.UnescapeDataString(confirmUrl));
ctx.HandleRequest();
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Authority.Extensions;
using Ablera.Serdica.Authority.Services;
using Microsoft.AspNetCore.Http;
using OpenIddict.Abstractions;
using OpenIddict.Server;
namespace Ablera.Serdica.Authority.OpenIddictServerHandlers;
public sealed class ValidateClientCredentialsRequest(OidcJsonSettingsProvider settings, IHttpContextAccessor http)
: IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext>
{
public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext ctx)
{
if (!ctx.Request.IsClientCredentialsGrantType())
return default;
var client = settings.Settings.RegisteredClients
.FirstOrDefault(c => c.ClientId == ctx.Request.ClientId);
if (client is null)
{
ctx.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown client.");
return default;
}
// Confidential clients: check secret.
if (!string.IsNullOrEmpty(client.ClientSecret))
{
if (!string.Equals(ctx.ClientSecret, client.ClientSecret, StringComparison.Ordinal))
ctx.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client secret.");
return default;
}
// Public/secret-less clients: enforce your allowed network masks.
var masks = (client.AllowedMasks ?? Enumerable.Empty<AllowedMask>())
.Concat(settings.Settings.AllowedMasks ?? Enumerable.Empty<AllowedMask>());
if (!masks.Any(m => m.MatchesRemote(http.HttpContext!)))
ctx.Reject(OpenIddictConstants.Errors.InvalidClient, "Client not allowed from this origin.");
// If were here and not rejected, we let the pipeline continue.
return default;
}
}

View File

@@ -0,0 +1,346 @@
using Ablera.Serdica.Common.Tools.Helpers;
using Ablera.Serdica.Microservice.Consumer.Config;
using Ablera.Serdica.Authority.Services;
using Ablera.Serdica.DBModels.Serdica;
using Serilog;
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using System;
using Ablera.Serdica.Extensions.Serilog;
using Microsoft.EntityFrameworkCore;
using Ablera.Serdica.DBModels.Oidc;
using Microsoft.AspNetCore.Builder;
using Ablera.Serdica.Authority.HostServices;
using Microsoft.IdentityModel.Tokens;
using Quartz;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication.Cookies;
using Ablera.Serdica.Authority.Extensions;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Authority.Contracts;
using Microsoft.AspNetCore.Identity;
using Ablera.Serdica.Common.Tools.Models.Config;
using Microsoft.IdentityModel.Protocols.Configuration;
using Microsoft.Extensions.Options;
using Ablera.Serdica.Authority.Constants;
using OpenIddict.Server;
using Ablera.Serdica.Authority.OpenIddictServerHandlers;
using Ablera.Serdica.DependencyInjection;
using Ablera.Serdica.UserConfiguration.Models;
using Ablera.Serdica.HealthChecks.Extensions;
using Microsoft.AspNetCore.StaticFiles;
using static OpenIddict.Server.OpenIddictServerEvents;
// Use the W3C format for Activity IDs.
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
// Create the WebApplicationBuilder instead of using Host.CreateDefaultBuilder.
// This model ensures that the built application supports middleware configuration.
var builder = WebApplication.CreateBuilder(args);
// Adjust configuration set the environment name (if provided by an environment variable)
// and add the "SERDICA_" prefixed environment variables.
{
var environmentName = Environment.GetEnvironmentVariable("SERDICA_PROJECT_ENV")
?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (!string.IsNullOrWhiteSpace(environmentName))
{
builder.Environment.EnvironmentName = environmentName;
}
builder.Configuration.AddEnvironmentVariables(prefix: "SERDICA_");
}
// Configure Serilog as the logging provider.
builder.Host.UseSerilog((context, _, configuration) =>
{
configuration.ReadFrom.Configuration(context.Configuration)
.Enrich.With(new MoveScopeToFieldsLogEventEnricher());
});
var jsonSettingsConfig = builder.Configuration.GetSection(nameof(JsonFileSettingsConfig)).Get<JsonFileSettingsConfig>() ?? new JsonFileSettingsConfig();
using var oidConfigProvider = new OidcJsonSettingsProvider(null, Options.Create(jsonSettingsConfig));
var oidcSettings = oidConfigProvider.Settings;
var issuerUrl = oidcSettings.IssuerUrl.TrimEnd('/');
var oidcEncryptionKey = Convert.FromBase64String(oidcSettings.EncryptionKey!)
?? throw new InvalidConfigurationException($"Invalid or no base64 key provided for {nameof(OidcServerSettings)}.{nameof(OidcServerSettings.EncryptionKey)}");
// Register Ablera Serdica configuration.
builder.Services
.ConfigureTools(builder.Configuration)
.AddRedisFromEntityFrameworkEntityCacheManager<Route, SerdicaDbContext>(
builder.Configuration, e => e.Id.ToString())
.AddDbContext<SerdicaDbContext>(
builder.Configuration, OptimizedSerdicaDbContextModel.Instance)
.AddDbContext<OidcDbContext>(
builder.Configuration, null, null, options =>
options
.UseOracle(
builder.Configuration.GetConnectionString(ConstantsClass.ConnectionNameDefault),
b => b.MigrationsAssembly(typeof(Ablera.Serdica.DBModels.Oidc.Migrations.OidcDbContextFactory).Assembly.GetName().Name))
.UseOpenIddict())
.AddInitializationRoutine<Ablera.Serdica.Microservice.Initializer.EndpointsRegistration.Initializer>()
.AddTranslationProvider(builder.Configuration)
.AddCacheManager(builder.Configuration)
.AddUserConfiguration<UserConfigurationModel>(builder.Configuration)
.AddRedis(builder.Configuration)
.AddRedisUserConfigurationRepository<UserConfigurationModel>(builder.Configuration)
.AddSerdicaUserConfigurationBuilder(builder.Configuration)
.AddPluginIntegrations(builder.Configuration)
.AddSystem(builder.Configuration)
.AddAsConsumerAsync(builder.Configuration);
// Register Ablera.Serdica.Authority services
builder.Services
.Configure<UserManagingDirectorConfig>(builder.Configuration.GetSection(nameof(UserManagingDirectorConfig)))
.Configure<FileServerConfig>(builder.Configuration.GetSection(nameof(FileServerConfig)))
.Configure<OidcServerSettings>(builder.Configuration.GetSection(nameof(OidcServerSettings)))
.AddSingleton<RoutesTreeProvider>()
.AddSingleton<OidcJsonSettingsProvider>()
.AddScoped<OidcClientSynchronizer>()
.AddSingleton<AuthenticationUrlBuilder>()
.AddScoped<IUserManagingDirector<IdentityUser<string>>, UserManagingDirector>()
.AddHostedService<RoutesTreeBuilderHostedService>()
.AddHostedService<OidcInfrastructureHostedService>();
// Get FileServerConfig to determine the correct paths with prefix
var fileServerConfig = builder.Configuration.GetSection(nameof(FileServerConfig)).Get<FileServerConfig>() ?? new FileServerConfig();
var pathPrefix = fileServerConfig.RootPathPrefixForWWW ?? string.Empty;
// Configure authentication using cookies.
builder
.Services
.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(1);
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
.AddHttpContextAccessor()
.AddCors(options =>
{
options
.AddDefaultPolicy(policy => policy
.SetIsOriginAllowed(_ => true) // Allow any origin
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.AllowCredentials());
})
.AddQuartz(options =>
{
options.UseSimpleTypeLoader();
options.UseInMemoryStore();
})
.AddQuartzHostedService(options => options.WaitForJobsToComplete = true)
.AddAuthorization()
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
var loginPath = string.IsNullOrEmpty(pathPrefix)
? "/login.html"
: $"{pathPrefix}/login.html";
var accessDeniedPath = string.IsNullOrEmpty(pathPrefix)
? oidcSettings.Endpoints.Authorization.EnsureStartsWith("/")
: $"{pathPrefix}{oidcSettings.Endpoints.Authorization.EnsureStartsWith("/")}";
var logoutPath = string.IsNullOrEmpty(pathPrefix)
? oidcSettings.Endpoints.Logout.EnsureStartsWith("/")
: $"{pathPrefix}{oidcSettings.Endpoints.Logout.EnsureStartsWith("/")}";
options.AccessDeniedPath = accessDeniedPath;
options.LoginPath = loginPath;
options.LogoutPath = logoutPath;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.None;
options.Cookie.Name = oidcSettings.CookieName;
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromMinutes(oidcSettings.CookieExpirationInMinutes);
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = x => x.HandlerRedirectToLogin()
};
}).Services
.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>, ConfigureCookieTicketStore>()
.AddSingleton<ITicketStore, RedisTicketStore>()
.AddRedis(builder.Configuration);
// Register health checks
builder.Services
.AddHealthChecks(builder.Configuration, typeof(SerdicaDbContext), typeof(OidcDbContext))
.AddRedis(builder.Configuration)
.AddRabbitMQ(builder.Configuration, builder.Services);
builder.Services
.AddDataProtection(builder.Configuration);
// Register OpenIddict.
builder.Services.AddOpenIddict()
.AddCore(options =>
{
// Use your Oracle-based SerdicaDbContext for OpenIddict's stores.
options.UseEntityFrameworkCore()
.UseDbContext<OidcDbContext>();
options.UseQuartz();
})
.AddServer(options =>
{
options.SetIssuer(new Uri(issuerUrl));
options.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(oidcSettings.AuthorizationTokenDurationInMinutes));
// Get FileServerConfig to apply path prefix to endpoints
var fileServerConfig = builder.Configuration.GetSection(nameof(FileServerConfig)).Get<FileServerConfig>() ?? new FileServerConfig();
var pathPrefix = fileServerConfig.RootPathPrefixForWWW ?? string.Empty;
options
.SetAuthorizationEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Authorization.EnsureStartsWith("/")}")
.SetDeviceAuthorizationEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Device.EnsureStartsWith("/")}")
.SetIntrospectionEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Introspection.EnsureStartsWith("/")}")
.SetEndSessionEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Logout.EnsureStartsWith("/")}")
.SetTokenEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Token.EnsureStartsWith("/")}")
.SetUserInfoEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Userinfo.EnsureStartsWith("/")}")
.SetRevocationEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Revocation.EnsureStartsWith("/")}")
.SetEndUserVerificationEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.EndUserVerification.EnsureStartsWith("/")}")
.SetJsonWebKeySetEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Jwks.EnsureStartsWith("/")}")
.SetConfigurationEndpointUris(
$"{pathPrefix}{oidcSettings.Endpoints.Configuration.EnsureStartsWith("/")}");
options
.AllowAuthorizationCodeFlow()
.AllowHybridFlow()
.AllowClientCredentialsFlow()
.AcceptAnonymousClients()
.AllowPasswordFlow()
.AllowRefreshTokenFlow()
.AllowDeviceAuthorizationFlow()
.AllowNoneFlow();
options.AddEventHandler<HandleAuthorizationRequestContext>(
x => x
.UseScopedHandler<AuthorizationRequestHandler>()
.SetOrder(int.MinValue)
//.SetOrder(OpenIddictServerHandlers.Authentication.ValidateAuthentication.Descriptor.Order + 1)
.SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler<ValidateTokenRequestContext>(x => x
.UseScopedHandler<ValidateClientCredentialsRequest>()
.SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler<HandleTokenRequestContext>(
x => x
.UseScopedHandler<ClientCredentialsGrantHandler>()
.SetOrder(OpenIddictServerHandlers.ValidateIdentityToken.Descriptor.Order + 1)
.SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler<HandleTokenRequestContext>(
x => x
.UseScopedHandler<PasswordGrantHandler>()
.SetOrder(OpenIddictServerHandlers.ValidateIdentityToken.Descriptor.Order + 2)
.SetType(OpenIddictServerHandlerType.Custom));
options.AddEventHandler<HandleEndSessionRequestContext>(
x => x.UseScopedHandler<EndSessionHandler>());
options.RegisterClaims(oidcSettings.Claims);
options.RegisterScopes(oidcSettings.Scopes);
options.RequireProofKeyForCodeExchange();
// Use development certificates replace with a production certificate in real applications.
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options.UseDataProtection()
.PreferDefaultAccessTokenFormat();
options.AddEncryptionKey(new SymmetricSecurityKey(oidcEncryptionKey));
var aspNetCoreConfiguration = options.UseAspNetCore();
aspNetCoreConfiguration.EnableStatusCodePagesIntegration();
if (oidcSettings.RequireHttps != true)
{
aspNetCoreConfiguration.DisableTransportSecurityRequirement();
}
})
.AddValidation(options =>
{
options.UseLocalServer();
options.AddEncryptionKey(new SymmetricSecurityKey(oidcEncryptionKey));
options.UseSystemNetHttp();
options.UseAspNetCore();
options.UseDataProtection();
options.EnableAuthorizationEntryValidation();
});
// Build the WebApplication.
var app = builder.Build();
// Configure the middleware pipeline.
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app
.UseForwardedHeadersExt(builder.Configuration)
.UseRouting();
// Configure static files with path prefix support
if (!string.IsNullOrEmpty(fileServerConfig.RootPathPrefixForWWW))
{
// Serve static files with path prefix (e.g., /identity)
app.UseStaticFiles(new StaticFileOptions
{
RequestPath = fileServerConfig.RootPathPrefixForWWW
});
}
else
{
// Serve static files at root level (default behavior)
app.UseStaticFiles();
}
app
.UseCors()
.UseAuthentication()
.UseAuthorization();
app.MapHealthChecks();
// Bind the service provider if needed (legacy support).
ServiceProviderAccessor.Initialize(app.Services);
// Start the application within a try/catch to log errors.
try
{
Log.Information("Starting application with issuer url {issuerUrl}", issuerUrl);
ServiceProviderAccessor.Initialize(app.Services);
await app.RunAsync();
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly.");
Console.WriteLine("Host terminated unexpectedly. " +
"Exception: " + ex.Message + Environment.NewLine +
"Stacktrace: " + ex.StackTrace);
return 1;
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,18 @@
{
"profiles": {
"SelfHost": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"SERDICA_PROJECT": "dev",
"SERDICA_PROJECT_ENV": "development",
"SERDICA_PROJECT_INSTANCE": "local",
"SERDICA_RUNTIME": "local",
"SERDICA_Serilog__WriteTo__0__Args__configure__0__Args__outputTemplate": "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}(UserId {SerdicaUserId} | RequestId {RequestId}){NewLine}{Message:lj}{NewLine}{Exception}{NewLine}",
"SERDICA_Serilog__WriteTo__0__Args__configure__0__Args__theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
"ASPNETCORE_URLS": "https://localhost:57001;http://localhost:57000;http://authority:57000"
},
"applicationUrl": "https://localhost:57001;http://localhost:57000;http://authority:57000"
}
}
}

View File

@@ -0,0 +1 @@
dotnet ef migrations add InitialOpenIddictMigration --context OidcDbContext --project "..\..\..\Common\CommonCustomLibraries\Ablera.Serdica.DBModels.Oidc.Migrations\Ablera.Serdica.DBModels.Oidc.Migrations.csproj" --startup-project "..\Ablera.Serdica.Users.csproj"

View File

@@ -0,0 +1,49 @@
using System;
using System.Linq;
using Ablera.Serdica.Authentication.Models.Oidc;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Ablera.Serdica.Authority.Services;
public class AuthenticationUrlBuilder(ILogger<AuthenticationUrlBuilder> logger, OidcJsonSettingsProvider oidcJsonSettingsProvider)
{
public string? BuildAuthenticationUrl(
string clientId,
string authenticationDelegateUrl,
HttpRequest request)
{
var redirectUrls = oidcJsonSettingsProvider.Settings.RegisteredClients.Where(x => x.ClientId == clientId)
.SelectMany(x => x.RedirectUris ?? [])
.ToArray();
if (redirectUrls.Length == 0)
{
logger.LogError($"No {nameof(RegisteredClient.RedirectUris)} configured for client with id {clientId}", clientId);
return null;
}
string? redirectUrl = null;
var refererHeader = request.Headers["Referer"].ToString();
if (!string.IsNullOrEmpty(refererHeader) && redirectUrls.Any(x => refererHeader.StartsWith(x)))
{
var refererUri = new Uri(refererHeader);
redirectUrl = $"{refererUri.Scheme}://{refererUri.Host}{(refererUri.IsDefaultPort ? "" : $":{refererUri.Port}")}{refererUri.AbsolutePath}";
}
if (redirectUrl == null)
{
redirectUrl = redirectUrls[0];
logger.LogWarning("Unable to determine client url from headers. Will use default redirect url instead {issuerUrl}",
redirectUrl);
}
var processedDelegateUrl = authenticationDelegateUrl
.Replace("{{issuer_url}}", oidcJsonSettingsProvider.Settings.IssuerUrl)
.Replace("{{redirect_url}}", redirectUrl ?? string.Empty);
var authorizationConfirmUrl =
$"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}";
var authenticationUrl = processedDelegateUrl + "&confirmUrl=" + Uri.EscapeDataString(authorizationConfirmUrl);
return authenticationUrl;
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Services;
public sealed class ConfigureCookieTicketStore(ITicketStore store)
: IPostConfigureOptions<CookieAuthenticationOptions>
{
public void PostConfigure(string? scheme, CookieAuthenticationOptions opts)
=> opts.SessionStore = store;
}

View File

@@ -0,0 +1,159 @@
using Ablera.Serdica.Authentication.Models.Oidc;
using Ablera.Serdica.Authority.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Ablera.Serdica.Authority.Services;
/// <summary>
/// Synchronizes OpenIddict client registrations from configuration, performing upserts
/// and only applying changes when the descriptor differs from stored values.
/// </summary>
public class OidcClientSynchronizer(
IOpenIddictApplicationManager manager,
OidcJsonSettingsProvider settingsProvider,
ILogger<OidcClientSynchronizer> logger)
{
/// <summary>
/// Reads configured clients and upserts them into OpenIddict, applying changes only when needed.
/// </summary>
public async Task SynchronizeAsync(CancellationToken cancellationToken = default)
{
// Iterate all clients from JSON settings
foreach (var client in settingsProvider.Settings.RegisteredClients)
{
// Build the descriptor from config, injecting dynamic UI URLs when applicable
var descriptor = BuildDescriptor(client);
// Upsert the application
await UpsertClientAsync(descriptor, cancellationToken);
}
}
private OpenIddictApplicationDescriptor BuildDescriptor(
RegisteredClient client)
{
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = client.ClientId,
ClientType = client.ClientType switch
{
OpenIddictConstants.ClientTypes.Public => OpenIddictConstants.ClientTypes.Public,
OpenIddictConstants.ClientTypes.Confidential => OpenIddictConstants.ClientTypes.Confidential,
_ => throw new InvalidOperationException("Unknown client type")
},
DisplayName = client.DisplayName,
// ClientSecret may be null for public clients
ClientSecret = client.ClientSecret,
};
// non-UI clients: use static values
foreach (var uri in client.RedirectUris ?? [])
descriptor.RedirectUris.Add(new Uri(uri, UriKind.Absolute));
foreach (var uri in client.PostLogoutRedirectUris ?? [])
descriptor.PostLogoutRedirectUris.Add(new Uri(uri, UriKind.Absolute));
// copy over any custom Properties
foreach (var kv in client.Properties ?? [])
descriptor.Properties[kv.Key] = kv.Value;
// Copy permissions and requirements
client.Permissions?.ToList()?.ForEach(x => descriptor.Permissions.Add(x));
client.Requirements?.ToList()?.ForEach(x => descriptor.Requirements.Add(x));
return descriptor;
}
private async Task UpsertClientAsync(
OpenIddictApplicationDescriptor descriptor,
CancellationToken cancellationToken)
{
var existing = await manager.FindByClientIdAsync(
descriptor.ClientId ?? throw new ArgumentNullException(nameof(descriptor.ClientId)),
cancellationToken);
if (existing is null)
{
try
{
await manager.CreateAsync(descriptor, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create OIDC client '{ClientId}'", descriptor.ClientId);
throw;
}
logger.LogInformation("Created OIDC client '{ClientId}'", descriptor.ClientId);
return;
}
// Compare existing settings to the descriptor
if (!await NeedsUpdateAsync(existing, descriptor, cancellationToken))
{
logger.LogDebug("No changes for client '{ClientId}', skipping update.", descriptor.ClientId);
return;
}
// Perform update
await manager.UpdateAsync(existing, descriptor, cancellationToken);
logger.LogInformation("Updated OIDC client '{ClientId}'", descriptor.ClientId);
}
private async Task<bool> NeedsUpdateAsync(
object existing,
OpenIddictApplicationDescriptor descriptor,
CancellationToken cancellationToken)
{
var existingRedirectUris = (await manager
.GetRedirectUrisAsync(existing, cancellationToken))
.ToHashSet(StringComparer.Ordinal);
var descriptorRedirectUris = descriptor.RedirectUris
.Select(u => u.OriginalString)
.ToHashSet(StringComparer.Ordinal);
if (!existingRedirectUris.SetEquals(descriptorRedirectUris)) return true;
var existingPostLogoutRedirectUris = (await manager
.GetPostLogoutRedirectUrisAsync(existing, cancellationToken))
.ToHashSet(StringComparer.Ordinal);
var descriptorPostLogoutRedirectUris = descriptor.PostLogoutRedirectUris
.Select(u => u.OriginalString)
.ToHashSet(StringComparer.Ordinal);
if (!existingPostLogoutRedirectUris.SetEquals(descriptorPostLogoutRedirectUris)) return true;
// Load permissions, requirements, client type, and check secret
var existingPerms = (await manager.GetPermissionsAsync(existing, cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!existingPerms.SetEquals(descriptor.Permissions)) return true;
var existingReqs = (await manager.GetRequirementsAsync(existing, cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!existingPerms.SetEquals(descriptor.Permissions)) return true;
var existingType = await manager.GetClientTypeAsync(existing, cancellationToken);
if (!string.Equals(existingType, descriptor.ClientType, StringComparison.OrdinalIgnoreCase)) return true;
bool secretChanged = false;
if (!string.IsNullOrWhiteSpace(descriptor.ClientSecret))
{
secretChanged = !await manager.ValidateClientSecretAsync(
existing, descriptor.ClientSecret, cancellationToken);
}
if (secretChanged) return true;
var existingProperties = (await manager.GetPropertiesAsync(existing, cancellationToken));
if (!descriptor.Properties.DictionaryEquals(existingProperties, JsonElementEqualityComparer.Default)) return true;
return false;
}
}

View File

@@ -0,0 +1,24 @@
using Ablera.Serdica.Common.Tools;
using Ablera.Serdica.Common.Tools.Models.Config;
using Ablera.Serdica.Authority.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Services;
public class OidcJsonSettingsProvider : GenericJsonSettingsProvider<OidcServerSettings>
{
public const string JsonFilePath = "oidc-settings.json";
public OidcJsonSettingsProvider(
ILogger<GenericJsonSettingsProvider<OidcServerSettings>>? logger,
IOptions<JsonFileSettingsConfig> options)
: base(logger, options, JsonFilePath, null)
{
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Ablera.Serdica.Authentication.Models;
using Ablera.Serdica.Authority.Models;
namespace Ablera.Serdica.Authority.Services;
public sealed class RedisTicketStore(IDistributedCache cache, IOptions<OidcServerSettings> options) : ITicketStore
{
private static readonly TicketSerializer serializer = TicketSerializer.Default;
private const string Prefix = "auth_ticket_";
private readonly TimeSpan lifetime = TimeSpan.FromMinutes(options.Value.CookieExpirationInMinutes);
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var key = CreateKey();
await RenewAsync(key, ticket);
return key;
}
public Task RenewAsync(string key, AuthenticationTicket ticket)
{
var bytes = serializer.Serialize(ticket);
var opts = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = lifetime,
SlidingExpiration = lifetime
};
return cache.SetAsync(Prefix + key, bytes, opts);
}
public async Task<AuthenticationTicket?> RetrieveAsync(string key)
{
var bytes = await cache.GetAsync(Prefix + key);
return bytes is null ? null : serializer.Deserialize(bytes);
}
public Task RemoveAsync(string key)
=> cache.RemoveAsync(Prefix + key);
// --------------- helpers ----------------------
private static string CreateKey()
{
// 32 random bytes > SHA-256 > Base64-url
Span<byte> rnd = stackalloc byte[32];
RandomNumberGenerator.Fill(rnd);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(rnd, hash);
return WebEncoders.Base64UrlEncode(hash);
}
}

View File

@@ -0,0 +1,13 @@
using Ablera.Serdica.Authority.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Ablera.Serdica.Authority.Services;
public class RoutesTreeProvider
{
public IReadOnlyList<RouteEntity>? Tree { get; set; }
}

View File

@@ -0,0 +1,268 @@
using Ablera.Serdica.Common.Tools.Extensions;
using Ablera.Serdica.Authority.Contracts;
using Ablera.Serdica.Authority.Models;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.Authority.Plugins.Base.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Ablera.Serdica.DBModels.Serdica;
using Microsoft.Extensions.Logging;
namespace Ablera.Serdica.Authority.Services;
public class UserManagingDirector(
SerdicaDbContext dbContext,
ILogger<UserManagingDirector> logger,
IEnumerable<IUserManagementFacade<IdentityUser<string>>> userManagers,
IOptions<UserManagingDirectorConfig> options)
: IUserManagingDirector<IdentityUser<string>>
{
// --------------------------------------------------------------------
// Configuration taken from appsettings → injected via IOptions
// --------------------------------------------------------------------
private readonly UserManagingDirectorConfig _cfg = options.Value;
// --------------------------------------------------------------------
// Priority table bigger number = stronger / more important error
// --------------------------------------------------------------------
private static readonly IReadOnlyDictionary<string, int> ErrorRank =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
[AuthenticationCode.AccountIsLocked.ToScreamingSnakeCase()] = 400,
[AuthenticationCode.AccountIsNotActive.ToScreamingSnakeCase()] = 300,
[AuthenticationCode.InvalidPassword.ToScreamingSnakeCase()] = 200,
[AuthenticationCode.InvalidCredentials.ToScreamingSnakeCase()] = 200,
[AuthenticationCode.AccountIsNotFound.ToScreamingSnakeCase()] = 100,
[AuthenticationCode.NoAuthBackend.ToScreamingSnakeCase()] = 0
};
// --------------------------------------------------------------------
// Helpers that pick the “stronger” result
// --------------------------------------------------------------------
private static AuthenticationResult Pick(AuthenticationResult? a, AuthenticationResult? b)
{
if (a is null) return b!;
if (b is null) return a;
var ra = ErrorRank.GetValueOrDefault(a.ErrorCode ?? string.Empty, -1);
var rb = ErrorRank.GetValueOrDefault(b.ErrorCode ?? string.Empty, -1);
return rb > ra ? b : a;
}
private static OperationResult Pick(OperationResult? a, OperationResult? b)
{
if (a is null) return b!;
if (b is null) return a;
// Success beats any failure
if (a.Succeeded && !b.Succeeded) return a;
if (b.Succeeded && !a.Succeeded) return b;
// Both success or both failure → use the ranking table
var ra = ErrorRank.GetValueOrDefault(a.ErrorCode ?? string.Empty, -1);
var rb = ErrorRank.GetValueOrDefault(b.ErrorCode ?? string.Empty, -1);
return rb > ra ? b : a;
}
// ====================================================================
// 1. Authentication
// ====================================================================
public async Task<AuthenticationResult> AuthenticateAsync(
IdentityUser<string> user,
string password,
bool lockoutOnFailure = false,
CancellationToken ct = default)
{
if (userManagers.Any() == false)
{
logger.LogWarning("No any backend authorization backend are found. Did you install any plugins?");
return AuthenticationResult.Fail(AuthenticationCode.NoAuthBackend.ToScreamingSnakeCase());
}
AuthenticationResult? aggregate = null;
foreach (var manager in userManagers)
{
var res = await manager.AuthenticateAsync(user, password, lockoutOnFailure, ct);
if (res.Succeeded) // success wins instantly
return res;
aggregate = Pick(aggregate, res); // remember strongest error
if (!_cfg.LoginAnywhere) // only first backend is allowed
break;
}
return aggregate
?? AuthenticationResult.Fail(AuthenticationCode.NoAuthBackend.ToScreamingSnakeCase());
}
// ====================================================================
// 2. WRITE operations (propagation depends on UpdateEveryWhere flag)
// ====================================================================
public Task<OperationResult> CreateAsync(
IdentityUser<string> user,
string password,
CancellationToken ct = default)
=> PropagateAsync(mgr => mgr.CreateAsync(user, password, ct));
public Task<OperationResult> ChangePasswordAsync(
IdentityUser<string> user,
string currentPassword,
string newPassword,
CancellationToken ct = default)
=> PropagateAsync(mgr => mgr.ChangePasswordAsync(user, currentPassword, newPassword, ct));
public Task<OperationResult> ResetPasswordAsync(
IdentityUser<string> user,
string token,
string newPassword,
CancellationToken ct = default)
=> PropagateAsync(mgr => mgr.ResetPasswordAsync(user, token, newPassword, ct));
public Task<OperationResult> UpdateAsync(
IdentityUser<string> user,
CancellationToken ct = default)
=> PropagateAsync(mgr => mgr.UpdateAsync(user, ct));
public Task<OperationResult> LockAsync(
IdentityUser<string> user,
CancellationToken ct = default)
=> PropagateAsync(mgr => mgr.LockAsync(user, ct));
public Task<OperationResult> UnlockAsync(
IdentityUser<string> user,
CancellationToken ct = default)
=> PropagateAsync(mgr => mgr.UnlockAsync(user, ct));
// --------------------------------------------------------------------
// Shared propagator for all write operations
// --------------------------------------------------------------------
private async Task<OperationResult> PropagateAsync(
Func<IUserManagementFacade<IdentityUser<string>>, Task<OperationResult>> call)
{
OperationResult? aggregate = null;
foreach (var mgr in userManagers)
{
var res = await call(mgr);
aggregate = Pick(aggregate, res);
if (!_cfg.UpdateEveryWhere) // stop after first try
break;
if (!res.Succeeded) // stop propagation on first failure
break;
}
return aggregate
?? OperationResult.Fail(AuthenticationCode.NoAuthBackend.ToScreamingSnakeCase());
}
// We seek on first mgr able to login and return the first non-null result.
private async Task<IdentityUser<string>?> FindUserAsync(
Func<IUserManagementFacade<IdentityUser<string>>,
CancellationToken,
Task<IdentityUser<string>?>> finder,
CancellationToken ct)
{
foreach (var mgr in userManagers)
{
var user = await finder(mgr, ct);
if (user is not null)
{
if (string.IsNullOrEmpty(user.Id)) // some backends may not have the Id populated, restore it from the DB
{
if (string.IsNullOrEmpty(user.Email) == false)
{
user.Id = dbContext.UserAccounts.Where(x => x.UserEmail == user.Email).Select(x => x.UserGuid).FirstOrDefault();
}
else if (string.IsNullOrEmpty(user.UserName) == false)
{
user.Id = dbContext.UserAccounts.Where(x => x.UserName == user.UserName).Select(x => x.UserGuid).FirstOrDefault();
}
}
if (string.IsNullOrWhiteSpace(user.Id) == false)
{
return user; // found user with ID, return it
}
}
if (!_cfg.LoginAnywhere) // stop after first backend → “not found”
return null;
}
// searched all backends
return null;
}
// ====================================================================
// 3. READ operations
// ====================================================================
public Task<IdentityUser<string>?> FindByEmailAsync(string email, CancellationToken ct = default)
=> FindUserAsync((mgr, token) => mgr.FindByEmailAsync(email, token), ct);
public Task<IdentityUser<string>?> FindByNameAsync(string username, CancellationToken ct = default)
=> FindUserAsync((mgr, token) => mgr.FindByNameAsync(username, token), ct);
public Task<IdentityUser<string>?> FindByIdAsync(string id, CancellationToken ct = default)
=> FindUserAsync((mgr, token) => mgr.FindByIdAsync(id, token), ct);
// --------------------------------------------------------------------
// Claims aggregation remove duplicates afterwards
// --------------------------------------------------------------------
public async Task<IReadOnlyCollection<Claim>> GetBaseClaimsAsync(
IdentityUser<string> user,
CancellationToken ct = default)
{
var bag = new List<Claim>();
foreach (var mgr in userManagers)
{
var c = await mgr.GetBaseClaimsAsync(user, ct);
if (c.Count > 0) bag.AddRange(c);
if (!_cfg.LoginAnywhere) break;
}
return bag.Distinct(new ClaimComparer()).ToList().AsReadOnly();
}
public async Task<IReadOnlyCollection<Claim>?> GetRolesClaimsAsync(
IdentityUser<string> user,
CancellationToken ct = default)
{
var all = new List<Claim>();
foreach (var mgr in userManagers)
{
var c = await mgr.GetRolesClaimsAsync(user, ct);
if (c != null) all.AddRange(c);
if (!_cfg.LoginAnywhere) break;
}
return all.Distinct(new ClaimComparer()).ToList().AsReadOnly();
}
// --------------------------------------------------------------------
// Claim structural equality helper
// --------------------------------------------------------------------
private sealed class ClaimComparer : IEqualityComparer<Claim>
{
public bool Equals(Claim? x, Claim? y)
=> x?.Type == y?.Type &&
x?.Value == y?.Value &&
x?.ValueType == y?.ValueType;
public int GetHashCode(Claim obj)
=> HashCode.Combine(obj.Type, obj.Value, obj.ValueType);
}
}

View File

@@ -0,0 +1,89 @@
{
"Serilog": {
"Using": [
"Serilog.Sinks.Async",
"Serilog.Sinks.Console"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Information",
"Microsoft.Hosting.Lifetime": "Debug",
"System": "Information"
}
},
"WriteTo": [
{
"Name": "Async",
"Args": {
"configure": [
{
"Name": "Console",
"Args": {}
}
]
}
}
]
},
"RabbitConfig": {
"HostName": "serdica.ablera.dev",
"UserName": "ablera",
"Password": "AblerA2022",
"Port": 5672,
"ParallelConsumersCount": 2,
"ConsumerPrefetchCount": 1,
"Exchange": "authority",
"RequestQueueName": "authority.request"
},
"MicroserviceConfig": {
"SectionName": "Authority",
"ExchangeName": "authority",
"DefaultAllowedRoles": [ "DBA" ],
"DefaultTimeout": "00:00:15"
},
"RedisConfig": {
"ServerUrl": "serdica.ablera.dev:6379",
"Password": "AblerA2022"
},
"ConnectionStrings": {
"DefaultConnection": "DATA SOURCE=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=db.serdica.ablera.dev)(PORT=1521))(CONNECT_DATA=(SID=orcl1)));USER ID=srd_sys;PASSWORD=srd_sys"
},
"ConnectionSettings": {
"Oracle": {
"KeepAlive": true,
"KeepAliveInterval": 60,
"KeepAliveTime": 10,
"MaxCachedQueries": 200
}
},
"UsersConfigurationSettings": {
"AuthorizationTokenDurationInMinutes": 6,
"CacheKey": "users-configuration",
"MinimumAutoLogoutMinutes": 5,
"MaximumAutoLogoutMinutes": 43000,
"DefaultAutoLoginInSeconds": null,
"IsAutoLogoutEnabled": true,
"DefaultMainOfficeCode": "0200",
"DefaultCountry": "BG",
"DefaultLanguage": "BG"
},
"UserManagingDirectorConfig": {
"LoginAnywhere": true,
"UpdateEveryWhere": false
},
"SerdicaConfig": {
"TrustedNetworks": [
"127.0.0.1/8",
"10.0.0.0/8",
"172.16.0.0/12"
]
},
"PluginsConfig": {
"PluginsDirectory": "PluginBinaries",
"PluginsOrder": [ "Ablera.Serdica.Authority.Plugin.Ldap", "Ablera.Serdica.Authority.Plugin.Bulstrad", "Ablera.Serdica.Authority.Plugin.Standard" ]
},
"FileServerConfig": {
"RootPathPrefixForWWW": ""
}
}

View File

@@ -0,0 +1,202 @@
{
"EncryptionKey": "MzEyMCU0IzAuMjQzZTIyNC4lSiNANTJuMzIxaFt6YXM=",
"IssuerUrl": "http://localhost:57000",
"RequireHttps": false,
"CookieName": "oauth2-authorization",
"CookieExpirationInMinutes": 2,
"AuthorizationTokenDurationInMinutes": 60,
"Claims": [
"address",
"birthdate",
"email",
"email_verified",
"family_name",
"gender",
"given_name",
"issuer",
"locale",
"middle_name",
"name",
"nickname",
"phone_number",
"phone_number_verified",
"picture",
"preferred_username",
"profile",
"subject",
"updated_at",
"website",
"zoneinfo"
],
"RegisteredClients": [
{
"GrantTypes": [ "client_credentials" ],
"ClientId": "int-tests",
"DisplayName": "Abacus client",
"ClientSecret": "Int_Tests_Secretz",
"ClientType": "confidential",
"BuiltinClaims": [
{
"Type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"Value": "DBA"
}
],
"Permissions": [
"ept:authorization",
"ept:token",
"ept:logout",
"gt:authorization_code",
"gt:implicit",
"gt:refresh_token",
"gt:client_credentials",
"rst:code",
"rst:code id_token",
"rst:code id_token token",
"rst:code token",
"rst:id_token",
"rst:id_token token",
"rst:token",
"scp:SerdicaAPI",
"scp:openid",
"scp:address",
"scp:email",
"scp:phone",
"scp:profile"
]
},
{
"GrantTypes": [ "client_credentials" ],
"ClientId": "beth-gpt-python",
"DisplayName": "Beth Client",
"ClientSecret": "goDRiDvkyrtv17NVEOkhp43SF2af6NSL",
"ClientType": "confidential",
"BuiltinClaims": [
{
"Type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"Value": "DBA"
}
],
"Permissions": [
"ept:authorization",
"ept:token",
"ept:logout",
"gt:authorization_code",
"gt:implicit",
"gt:refresh_token",
"gt:client_credentials",
"rst:code",
"rst:code id_token",
"rst:code id_token token",
"rst:code token",
"rst:id_token",
"rst:id_token token",
"rst:token",
"scp:SerdicaAPI",
"scp:openid",
"scp:address",
"scp:email",
"scp:phone",
"scp:profile"
]
},
{
"GrantTypes": [ "authorization_code", "implicit" ],
"ClientId": "serdica-ui",
"DisplayName": "Serdica UI",
"RedirectUris": [ "http://localhost:4200" ],
"PostLogoutRedirectUris": [ "http://localhost:4200/signout-callback.html" ],
"ClientType": "public",
"Properties": {
"authenticationDelegateUrl": "{{redirect_url}}/#/session/signin?signInUrl={{issuer_url}}/connect/token"
},
"Requirements": [ "fpkce" ],
"Permissions": [
"ept:authorization",
"ept:token",
"ept:logout",
"ept:revocation",
"ept:introspection",
"ept:userinfo",
"gt:authorization_code",
"gt:implicit",
"gt:refresh_token",
"rst:code",
"rst:code id_token",
"rst:code id_token token",
"rst:code token",
"rst:id_token",
"rst:id_token token",
"rst:token",
"scp:SerdicaAPI",
"scp:openid",
"scp:address",
"scp:email",
"scp:phone",
"scp:profile"
]
},
{
"GrantTypes": [ "authorization_code", "implicit" ],
"ClientId": "postman",
"DisplayName": "PostMan",
"RedirectUris": [ "https://oauth.pstmn.io/v1/callback" ],
"PostLogoutRedirectUris": [],
"ClientType": "public",
"Properties": {
"authenticationDelegateUrl": "{{issuer_url}}/login.html?signInUrl={{issuer_url}}/connect/token"
},
"Requirements": [ "fpkce" ],
"Permissions": [
"ept:authorization",
"ept:token",
"ept:logout",
"ept:revocation",
"ept:introspection",
"ept:userinfo",
"gt:authorization_code",
"gt:implicit",
"gt:refresh_token",
"rst:code",
"rst:code id_token",
"rst:code id_token token",
"rst:code token",
"rst:id_token",
"rst:id_token token",
"rst:token",
"scp:SerdicaAPI",
"scp:IdentityServerApi",
"scp:openid",
"scp:address",
"scp:email",
"scp:phone",
"scp:profile"
]
}
],
"Endpoints": {
"Authorization": "/connect/authorize",
"Device": "/connect/device",
"Introspection": "/connect/introspect",
"Token": "/connect/token",
"Userinfo": "/connect/userinfo",
"Logout": "/connect/endsession",
"CheckSession": "/connect/checksession",
"EndUserVerification": "/connect/verification",
"Revocation": "/connect/revocation",
"Jwks": "/connect/jwks",
"Message": "/connect/message",
"Configuration": "/.well-known/openid-configuration"
},
"Scopes": [
"SerdicaAPI",
"IdentityServerApi",
"api",
"address",
"email",
"phone",
"profile",
"offline_access",
"openid",
"roles"
]
}

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Sign-in</title>
<script src="https://cdn.tailwindcss.com/3.4.4"></script>
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen">
<form id="loginForm"
action="/connect/token"
method="post"
class="bg-white p-6 rounded shadow-md space-y-5 w-full max-w-sm">
<!-- OIDC fields required by the token endpoint -->
<input type="hidden" name="grant_type" value="password">
<input type="hidden" name="scope" id="scope">
<!-- confirmUrl is no longer in the body -->
<div class="text-center space-y-1">
<h2 class="text-xl font-semibold">Sign-in</h2>
<p class="text-xs text-gray-500">
for client <span id="clientId" class="font-mono font-bold">-</span><br />
against endpoint:<br />
<span id="authPath" class="font-mono font-bold break-all">-</span>
</p>
</div>
<label class="block">
<span class="text-gray-700">Email</span>
<input name="username" type="email"
autofocus
autocomplete="username" class="mt-1 w-full border rounded p-2" required>
</label>
<label class="block">
<span class="text-gray-700">Password</span>
<input name="password" type="password"
autocomplete="current-password" class="mt-1 w-full border rounded p-2" required>
</label>
<button type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded">
Sign in
</button>
</form>
<script>
// ───── Extract query-string parameters the login handler gave us ─────
const qs = new URLSearchParams(location.search);
const confirmUrl = decodeURIComponent(qs.get('confirmUrl') || '');
const signInUrl = qs.get('signInUrl') || '/connect/token';
// ---- Compose signInUrl with confirmUrl as query param --------------
function appendQueryParam(url, key, value) {
const hasQuery = url.includes('?');
const sep = hasQuery ? '&' : '?';
return url + sep + encodeURIComponent(key) + '=' + encodeURIComponent(value);
}
let finalSignInUrl = signInUrl;
if (confirmUrl) {
// Remove any existing confirmUrl param to avoid duplicates
const urlObj = new URL(signInUrl, location.origin);
urlObj.searchParams.delete('confirmUrl');
finalSignInUrl = urlObj.origin + urlObj.pathname + urlObj.search;
finalSignInUrl = appendQueryParam(finalSignInUrl, 'confirmUrl', confirmUrl);
}
// Set the form action to the new URL with confirmUrl as query param
document.getElementById('loginForm').action = finalSignInUrl;
// ---- Extract info FROM the decoded confirmUrl -----------------------
try {
const url = new URL(confirmUrl, location.origin);
const cId = url.searchParams.get('client_id') || '';
const authPretty = url.pathname + url.search;
document.getElementById('clientId').textContent = cId;
document.getElementById('authPath').textContent = signInUrl;
// Forward the exact same scopes to the password-token request
const scopes = url.searchParams.get('scope') || 'openid profile';
document.getElementById('scope').value = scopes;
} catch { /* confirmUrl missing or malformed leave blanks */ }
</script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Authors>Ablera</Authors>
<Company>Ablera</Company>
<Product>Serdica</Product>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="6.3.0" />
<PackageReference Include="Oracle.EntityFrameworkCore" Version="9.23.80" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.8.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Ablera.Serdica.DBModels.Oidc/Ablera.Serdica.DBModels.Oidc.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,281 @@
// <auto-generated />
using System;
using Ablera.Serdica.DBModels.Oidc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Oracle.EntityFrameworkCore.Metadata;
#nullable disable
namespace Ablera.Serdica.DBModels.Oidc.Migrations.Migrations
{
[DbContext(typeof(OidcDbContext))]
[Migration("20250416153520_InitialOpenIddictMigration")]
partial class InitialOpenIddictMigration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 128);
OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ApplicationType")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("ClientId")
.HasMaxLength(100)
.HasColumnType("NVARCHAR2(100)");
b.Property<string>("ClientSecret")
.HasMaxLength(256)
.HasColumnType("NVARCHAR2(256)");
b.Property<string>("ClientType")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("ConsentType")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("DisplayName")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("DisplayNames")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("JsonWebKeySet")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Permissions")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("PostLogoutRedirectUris")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Properties")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("RedirectUris")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Requirements")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Settings")
.HasColumnType("NVARCHAR2(2000)");
b.HasKey("Id");
b.HasIndex("ClientId")
.IsUnique()
.HasFilter("\"ClientId\" IS NOT NULL");
b.ToTable("OIDC_APPLICATIONS", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ApplicationId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<string>("Properties")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Scopes")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("NVARCHAR2(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.HasKey("Id");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OIDC_AUTHORIZATIONS", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("Description")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Descriptions")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("DisplayName")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("DisplayNames")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("Properties")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Resources")
.HasColumnType("NVARCHAR2(2000)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasFilter("\"Name\" IS NOT NULL");
b.ToTable("OIDC_SCOPES", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ApplicationId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("AuthorizationId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<string>("Payload")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("Properties")
.HasColumnType("NVARCHAR2(2000)");
b.Property<DateTime?>("RedemptionDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<string>("ReferenceId")
.HasMaxLength(100)
.HasColumnType("NVARCHAR2(100)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("NVARCHAR2(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.HasKey("Id");
b.HasIndex("AuthorizationId");
b.HasIndex("ReferenceId")
.IsUnique()
.HasFilter("\"ReferenceId\" IS NOT NULL");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OIDC_TOKENS", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Authorizations")
.HasForeignKey("ApplicationId");
b.Navigation("Application");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Tokens")
.HasForeignKey("ApplicationId");
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization")
.WithMany("Tokens")
.HasForeignKey("AuthorizationId");
b.Navigation("Application");
b.Navigation("Authorization");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");
b.Navigation("Tokens");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Navigation("Tokens");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,189 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Ablera.Serdica.DBModels.Oidc.Migrations.Migrations
{
/// <inheritdoc />
public partial class InitialOpenIddictMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "SRD_SYS");
migrationBuilder.CreateTable(
name: "OIDC_APPLICATIONS",
schema: "SRD_SYS",
columns: table => new
{
Id = table.Column<string>(type: "NVARCHAR2(450)", nullable: false),
ApplicationType = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
ClientId = table.Column<string>(type: "NVARCHAR2(100)", maxLength: 100, nullable: true),
ClientSecret = table.Column<string>(type: "NVARCHAR2(256)", maxLength: 256, nullable: true),
ClientType = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
ConcurrencyToken = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
ConsentType = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
DisplayName = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
DisplayNames = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
JsonWebKeySet = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
Permissions = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
PostLogoutRedirectUris = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
Properties = table.Column<string>(type: "NVARCHAR2(4000)", nullable: true),
RedirectUris = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
Requirements = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
Settings = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OIDC_APPLICATIONS", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OIDC_SCOPES",
schema: "SRD_SYS",
columns: table => new
{
Id = table.Column<string>(type: "NVARCHAR2(450)", nullable: false),
ConcurrencyToken = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
Description = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
Descriptions = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
DisplayName = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
DisplayNames = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
Name = table.Column<string>(type: "NVARCHAR2(200)", maxLength: 200, nullable: true),
Properties = table.Column<string>(type: "NVARCHAR2(4000)", nullable: true),
Resources = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OIDC_SCOPES", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OIDC_AUTHORIZATIONS",
schema: "SRD_SYS",
columns: table => new
{
Id = table.Column<string>(type: "NVARCHAR2(450)", nullable: false),
ApplicationId = table.Column<string>(type: "NVARCHAR2(450)", nullable: true),
ConcurrencyToken = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "TIMESTAMP(7)", nullable: true),
Properties = table.Column<string>(type: "NVARCHAR2(4000)", nullable: true),
Scopes = table.Column<string>(type: "NVARCHAR2(2000)", nullable: true),
Status = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "NVARCHAR2(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OIDC_AUTHORIZATIONS", x => x.Id);
table.ForeignKey(
name: "FK_OIDC_AUTHORIZATIONS_OIDC_APPLICATIONS_ApplicationId",
column: x => x.ApplicationId,
principalSchema: "SRD_SYS",
principalTable: "OIDC_APPLICATIONS",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "OIDC_TOKENS",
schema: "SRD_SYS",
columns: table => new
{
Id = table.Column<string>(type: "NVARCHAR2(450)", nullable: false),
ApplicationId = table.Column<string>(type: "NVARCHAR2(450)", nullable: true),
AuthorizationId = table.Column<string>(type: "NVARCHAR2(450)", nullable: true),
ConcurrencyToken = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "TIMESTAMP(7)", nullable: true),
ExpirationDate = table.Column<DateTime>(type: "TIMESTAMP(7)", nullable: true),
Payload = table.Column<string>(type: "CLOB", nullable: true),
Properties = table.Column<string>(type: "VARCHAR2(4000)", nullable: true),
RedemptionDate = table.Column<DateTime>(type: "TIMESTAMP(7)", nullable: true),
ReferenceId = table.Column<string>(type: "NVARCHAR2(100)", maxLength: 100, nullable: true),
Status = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "NVARCHAR2(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(type: "NVARCHAR2(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OIDC_TOKENS", x => x.Id);
table.ForeignKey(
name: "FK_OIDC_TOKENS_OIDC_APPLICATIONS_ApplicationId",
column: x => x.ApplicationId,
principalSchema: "SRD_SYS",
principalTable: "OIDC_APPLICATIONS",
principalColumn: "Id");
table.ForeignKey(
name: "FK_OIDC_TOKENS_OIDC_AUTHORIZATIONS_AuthorizationId",
column: x => x.AuthorizationId,
principalSchema: "SRD_SYS",
principalTable: "OIDC_AUTHORIZATIONS",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_OIDC_APPLICATIONS_ClientId",
schema: "SRD_SYS",
table: "OIDC_APPLICATIONS",
column: "ClientId",
unique: true,
filter: "\"ClientId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_OIDC_AUTHORIZATIONS_ApplicationId_Status_Subject_Type",
schema: "SRD_SYS",
table: "OIDC_AUTHORIZATIONS",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
migrationBuilder.CreateIndex(
name: "IX_OIDC_SCOPES_Name",
schema: "SRD_SYS",
table: "OIDC_SCOPES",
column: "Name",
unique: true,
filter: "\"Name\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_OIDC_TOKENS_ApplicationId_Status_Subject_Type",
schema: "SRD_SYS",
table: "OIDC_TOKENS",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
migrationBuilder.CreateIndex(
name: "IX_OIDC_TOKENS_AuthorizationId",
schema: "SRD_SYS",
table: "OIDC_TOKENS",
column: "AuthorizationId");
migrationBuilder.CreateIndex(
name: "IX_OIDC_TOKENS_ReferenceId",
schema: "SRD_SYS",
table: "OIDC_TOKENS",
column: "ReferenceId",
unique: true,
filter: "\"ReferenceId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OIDC_SCOPES",
schema: "SRD_SYS");
migrationBuilder.DropTable(
name: "OIDC_TOKENS",
schema: "SRD_SYS");
migrationBuilder.DropTable(
name: "OIDC_AUTHORIZATIONS",
schema: "SRD_SYS");
migrationBuilder.DropTable(
name: "OIDC_APPLICATIONS",
schema: "SRD_SYS");
}
}
}

View File

@@ -0,0 +1,278 @@
// <auto-generated />
using System;
using Ablera.Serdica.DBModels.Oidc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Oracle.EntityFrameworkCore.Metadata;
#nullable disable
namespace Ablera.Serdica.DBModels.Oidc.Migrations.Migrations
{
[DbContext(typeof(OidcDbContext))]
partial class OidcDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 128);
OracleModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("OpenIddict.EntityFramewor40004kCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ApplicationType")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("ClientId")
.HasMaxLength(100)
.HasColumnType("NVARCHAR2(100)");
b.Property<string>("ClientSecret")
.HasMaxLength(256)
.HasColumnType("NVARCHAR2(256)");
b.Property<string>("ClientType")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("ConsentType")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("DisplayName")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("DisplayNames")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("JsonWebKeySet")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Permissions")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("PostLogoutRedirectUris")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Properties")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("RedirectUris")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Requirements")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Settings")
.HasColumnType("VARCHAR2(4000)");
b.HasKey("Id");
b.HasIndex("ClientId")
.IsUnique()
.HasFilter("\"ClientId\" IS NOT NULL");
b.ToTable("OIDC_APPLICATIONS", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ApplicationId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<string>("Properties")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Scopes")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("NVARCHAR2(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.HasKey("Id");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OIDC_AUTHORIZATIONS", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("Description")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Descriptions")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("DisplayName")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("DisplayNames")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("NVARCHAR2(200)");
b.Property<string>("Properties")
.HasColumnType("VARCHAR2(4000)");
b.Property<string>("Resources")
.HasColumnType("VARCHAR2(4000)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasFilter("\"Name\" IS NOT NULL");
b.ToTable("OIDC_SCOPES", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ApplicationId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("AuthorizationId")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<string>("Payload")
.HasColumnType("CLOB");
b.Property<string>("Properties")
.HasColumnType("VARCHAR2(4000)");
b.Property<DateTime?>("RedemptionDate")
.HasColumnType("TIMESTAMP(7)");
b.Property<string>("ReferenceId")
.HasMaxLength(100)
.HasColumnType("NVARCHAR2(100)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("NVARCHAR2(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("NVARCHAR2(50)");
b.HasKey("Id");
b.HasIndex("AuthorizationId");
b.HasIndex("ReferenceId")
.IsUnique()
.HasFilter("\"ReferenceId\" IS NOT NULL");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OIDC_TOKENS", "SRD_SYS");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Authorizations")
.HasForeignKey("ApplicationId");
b.Navigation("Application");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Tokens")
.HasForeignKey("ApplicationId");
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization")
.WithMany("Tokens")
.HasForeignKey("AuthorizationId");
b.Navigation("Application");
b.Navigation("Authorization");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");
b.Navigation("Tokens");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Navigation("Tokens");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.IO;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Ablera.Serdica.DBModels.Oidc;
namespace Ablera.Serdica.DBModels.Oidc.Migrations;
public class OidcDbContextFactory : IDesignTimeDbContextFactory<OidcDbContext>
{
public OidcDbContext CreateDbContext(string[] args)
{
// Use the current directory as base path (which is typically the startup projects folder)
var basePath = Directory.GetCurrentDirectory();
// Build configuration from appsettings.json in the startup folder
var configuration = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
var connectionString = configuration.GetConnectionString("DefaultConnection");
var optionsBuilder = new DbContextOptionsBuilder<OidcDbContext>();
var migrationsAssembly = typeof(Ablera.Serdica.DBModels.Oidc.Migrations.OidcDbContextFactory).Assembly.GetName().Name;
System.Console.WriteLine($"Using migration assembly name: {migrationsAssembly}");
optionsBuilder.UseOracle(connectionString, b =>
b.MigrationsAssembly(migrationsAssembly))
.UseOpenIddict();
return new OidcDbContext(optionsBuilder.Options);
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Authors>Ablera</Authors>
<Company>Ablera</Company>
<Product>Serdica</Product>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="6.3.0" />
<PackageReference Include="Oracle.EntityFrameworkCore" Version="9.23.80" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.8.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations.Internal;
using Microsoft.Extensions.Configuration;
using OpenIddict.EntityFrameworkCore.Models;
namespace Ablera.Serdica.DBModels.Oidc;
public class OidcDbContext : DbContext
{
public OidcDbContext(DbContextOptions<OidcDbContext> options)
: base(options)
{
}
public virtual DbSet<OpenIddictEntityFrameworkCoreApplication> OpenIddictApplications { get; set; }
public virtual DbSet<OpenIddictEntityFrameworkCoreAuthorization> OpenIddictAuthorizations { get; set; }
public virtual DbSet<OpenIddictEntityFrameworkCoreScope> OpenIddictScopes { get; set; }
public virtual DbSet<OpenIddictEntityFrameworkCoreToken> OpenIddictTokens { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
builder.HasAnnotation("Relational:DefaultStringType", "NVARCHAR2(4000)");
base.OnModelCreating(builder);
builder.UseOpenIddict();
// Configure the OpenIddict Applications table.
builder.Entity<OpenIddictEntityFrameworkCoreApplication>(entity =>
{
// Map to table with prefix "OIDC_" in the "SRD_SYS" schema.
entity.ToTable("OIDC_APPLICATIONS", "SRD_SYS");
// Ensure that ClientId is unique.
entity.HasIndex(e => e.ClientId)
.IsUnique();
// Optionally configure column size for ClientSecret (Oracle commonly uses VARCHAR2).
entity.Property(e => e.ClientSecret)
.HasMaxLength(256);
// Additional tuning: you might also constrain DisplayName or ConsentType here.
});
// Configure the OpenIddict Authorizations table.
builder.Entity<OpenIddictEntityFrameworkCoreAuthorization>(entity =>
{
entity.ToTable("OIDC_AUTHORIZATIONS", "SRD_SYS");
});
// Configure the OpenIddict Scopes table.
builder.Entity<OpenIddictEntityFrameworkCoreScope>(entity =>
{
entity.ToTable("OIDC_SCOPES", "SRD_SYS");
// Typically, scopes have a unique name.
entity.HasIndex(e => e.Name)
.IsUnique();
});
// Configure the OpenIddict Tokens table.
builder.Entity<OpenIddictEntityFrameworkCoreToken>(entity =>
{
entity.ToTable("OIDC_TOKENS", "SRD_SYS");
// Create an index on ReferenceId for quick lookups.
entity.HasIndex(e => e.ReferenceId)
.IsUnique();
// Optionally, you can configure the max length for certain token fields.
// For example, if ReferenceId should be a VARCHAR2(100):
entity.Property(e => e.ReferenceId)
.HasMaxLength(100);
});
}
}

View File

@@ -0,0 +1,13 @@
###### generated-by: Ablera.Serdica.CiJobsBuilder 1.0.0 ######
FROM mirrors.ablera.dev/docker-mirror/dotnet/sdk:9.0-alpine AS build
WORKDIR /
COPY . .
WORKDIR /src/Serdica/Ablera.Serdica.Authority/__Plugins/Ablera.Serdica.Authority.Plugin.Bulstrad
RUN dotnet restore "Ablera.Serdica.Authority.Plugin.Bulstrad.csproj"
RUN dotnet publish "Ablera.Serdica.Authority.Plugin.Bulstrad.csproj" -c Release -o /app/PluginBinaries
RUN apk add --no-cache zip && \
cd / && zip -r /ablera-serdica-authority-plugin-bulstrad.zip app
FROM alpine:3.19 AS final
COPY --from=build /ablera-serdica-authority-plugin-bulstrad.zip /
LABEL org.opencontainers.image.description="Plugin artefacts only"

View File

@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
<!--
If the caller passes -p:CopyPluginBinariesToPath=… we respect that.
Otherwise we fall back to the local relative folder used in Visual Studio.
-->
<CopyPluginBinariesToPath Condition="'$(CopyPluginBinariesToPath)' == ''">$([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','Ablera.Serdica.Authority','PluginBinaries','$(MSBuildProjectName)'))</CopyPluginBinariesToPath>
</PropertyGroup>
<!-- Fires after **every** successful build, CLI or IDE -->
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<!-- Every file that just landed in the target directory -->
<ItemGroup>
<BuiltFiles Include="$(TargetDir)**\*.*" />
</ItemGroup>
<Message Importance="high" Text="Copying files: @(BuiltFiles->'%(RecursiveDir)%(Filename)%(Extension)') &#xD;&#xA; to: $(CopyPluginBinariesToPath)" />
<Copy SourceFiles="@(BuiltFiles)" DestinationFolder="$(CopyPluginBinariesToPath)" SkipUnchangedFiles="true" />
</Target>
<ItemGroup>
<Content Include="bulstrad-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../../__Libraries/Ablera.Serdica.Common.Tools/Ablera.Serdica.Common.Tools.csproj" />
<ProjectReference Include="..\..\..\..\__Libraries\Ablera.Serdica.Plugin\Ablera.Serdica.Plugin.csproj" />
<ProjectReference Include="..\Ablera.Serdica.Authority.Plugins.Base\Ablera.Serdica.Authority.Plugins.Base.csproj" />
<ProjectReference Include="..\Ablera.Serdica.Authority.Plugins.LdapUtilities\Ablera.Serdica.Authority.Plugins.LdapUtilities.csproj" />
</ItemGroup>
<Target Name="PostClean" AfterTargets="Clean">
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
<!-- obj -->
<RemoveDir Directories="$(BaseOutputPath)" />
<!-- bin -->
</Target>
</Project>

View File

@@ -0,0 +1,29 @@
using Ablera.Serdica.Authority.Plugins.LdapUtilities.Services;
using Ablera.Serdica.Authority.Plugin.Ldap.Models;
using Microsoft.Extensions.Logging;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Attributes;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Contracts;
namespace Ablera.Serdica.Authority.Plugin.Bulstrad;
/// <summary>
/// Customerspecific LDAP user manager that piggybacks on <see cref="LdapUserManager{TAccount}"/>
/// but adds Bulstradspecific semantics:
/// <list type="bullet">
/// <item>Accepts only <see cref="BulstradAdIdentity"/> objects.</item>
/// <item>Denies login if <c>bstDStatus != "active"</c>.</item>
/// <item>Emits extra role/department claims (<c>bstRole</c>, <c>departmentNumber</c>).</item>
/// </list>
/// </summary>
public sealed class BulstradAdIdentityFacade : LdapIdentityFacadeBase<BulstradAdIdentity, string>
{
public BulstradAdIdentityFacade(
ILogger<BulstradAdIdentityFacade> logger,
ILogger<LdapIdentityFacadeBase<BulstradAdIdentity, string>> logger2,
BulstradAsLdapSettingsProvider ldapSettingsProvider,
IEmailNormalizer emailNormalizer,
IUsernameNormalizer usernameNormalizer)
: base(logger2, ldapSettingsProvider, emailNormalizer, usernameNormalizer)
{
}
}

View File

@@ -0,0 +1,23 @@
using Ablera.Serdica.Common.Tools;
using Ablera.Serdica.Common.Tools.Models.Config;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Models;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Contracts;
namespace Ablera.Serdica.Authority.Plugin.Bulstrad;
public class BulstradAsLdapSettingsProvider : GenericJsonSettingsProvider<LdapSettings[]>, ILdapSettingsProvider
{
public const string JsonFileName = "bulstrad-settings.json";
public static readonly string JsonFilePath =
Path.GetDirectoryName(typeof(BulstradAsLdapSettingsProvider).Assembly.Location)
?? AppContext.BaseDirectory;
public BulstradAsLdapSettingsProvider(
ILogger<GenericJsonSettingsProvider<LdapSettings[]>> logger,
IOptions<JsonFileSettingsConfig> options)
: base(logger, options, JsonFileName, null, JsonFilePath)
{
}
}

View File

@@ -0,0 +1,159 @@
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.Authority.Plugins.Base.Models;
using Ablera.Serdica.Authority.Plugin.Ldap.Models;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Ablera.Serdica.Common.Tools.Extensions;
using Ablera.Serdica.Authority.Plugin.Ldap;
namespace Ablera.Serdica.Authority.Plugin.Bulstrad;
/// <summary>
/// Adapter exposing Bulstradspecific LDAP manager as <see cref="IUserManagementFacade{UserAccount}"/>.
/// </summary>
public class IdentityManagementFacade(BulstradAdIdentityFacade userRepository, BulstradAsLdapSettingsProvider settingsProvider) : IUserManagementFacade<IdentityUser<string>>
{
#region Authentication
public async Task<AuthenticationResult> AuthenticateAsync(IdentityUser<string> user, string password, bool lockoutOnFailure = false, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
if (ldap == null) return AuthenticationResult.Fail(AuthenticationCode.AccountIsNotFound.ToScreamingSnakeCase());
return await userRepository.AuthenticateAsync(ldap, password, lockoutOnFailure, ct);
}
#endregion
#region Store lookups
public async Task<IdentityUser<string>?> FindByEmailAsync(string email, CancellationToken ct = default)
=> Map(await userRepository.FindByEmailAsync(email, ct));
public async Task<IdentityUser<string>?> FindByNameAsync(string username, CancellationToken ct = default)
=> Map(await userRepository.FindByNameAsync(username, ct));
public async Task<IdentityUser<string>?> FindByIdAsync(string id, CancellationToken ct = default)
=> Map(await userRepository.FindByIdAsync(id, ct));
public async Task<OperationResult> CreateAsync(IdentityUser<string> user, string password, CancellationToken ct = default)
{
if (user == null) return OperationResult.Fail("NULL_USER");
var ldap = new BulstradAdIdentity
{
Username = user.UserName,
Email = user.Email ?? user.UserName,
ObjectClasses = ["top", "person", "organizationalPerson", "inetorgperson"],
Identity = user,
LdapSettings = settingsProvider.Settings.First()
};
return await userRepository.CreateAsync(ldap, password, ct);
}
public async Task<OperationResult> UpdateAsync(IdentityUser<string> user, CancellationToken ct = default)
{
if (user == null) return OperationResult.Fail("NULL_USER");
var ldapIdentity = await ResolveAsync(user, ct);
if (ldapIdentity == null) return OperationResult.Fail("USER_NOT_FOUND");
ldapIdentity.Username = user.UserName;
ldapIdentity.Email = user.Email;
ldapIdentity.ObjectClasses = [ "top", "person", "organizationalPerson", "inetorgperson"];
ldapIdentity.Identity = user;
ldapIdentity.LdapSettings = settingsProvider.Settings.First();
return await userRepository.UpdateAsync(ldapIdentity, ct);
}
#endregion
#region Claims
public async Task<IReadOnlyCollection<Claim>> GetBaseClaimsAsync(IdentityUser<string> user, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
return ldap == null ? Array.Empty<Claim>() : await userRepository.GetBaseClaimsAsync(ldap, ct);
}
public async Task<IReadOnlyCollection<Claim>?> GetRolesClaimsAsync(IdentityUser<string> user, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
return ldap == null ? null : await userRepository.GetRolesClaimsAsync(ldap, ct);
}
#endregion
#region Password & lock
public async Task<OperationResult> ChangePasswordAsync(IdentityUser<string> user, string currentPassword, string newPassword, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
return ldap == null ? OperationResult.Fail("USER_NOT_FOUND") : await userRepository.ChangePasswordAsync(ldap, currentPassword, newPassword, ct);
}
public async Task<OperationResult> ResetPasswordAsync(IdentityUser<string> user, string token, string newPassword, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
return ldap == null ? OperationResult.Fail("USER_NOT_FOUND") : await userRepository.ResetPasswordAsync(ldap, token, newPassword, ct);
}
public async Task<OperationResult> LockAsync(IdentityUser<string> user, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
return ldap == null ? OperationResult.Fail("USER_NOT_FOUND") : await userRepository.LockAsync(ldap, ct);
}
public async Task<OperationResult> UnlockAsync(IdentityUser<string> user, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
return ldap == null ? OperationResult.Fail("USER_NOT_FOUND") : await userRepository.UnlockAsync(ldap, ct);
}
#endregion
#region Helper mapping
private async Task<BulstradAdIdentity?> ResolveAsync(IdentityUser<string> u, CancellationToken ct)
{
if (u == null) return null;
if (string.IsNullOrWhiteSpace(u.Email) == false)
{
var ret = await userRepository.FindByEmailAsync(u.Email, ct);
if (ret != null)
{
ret.Identity = u;
return ret;
}
}
if (string.IsNullOrWhiteSpace(u.UserName) == false)
{
var ret = await userRepository.FindByNameAsync(u.UserName, ct);
if (ret != null)
{
ret.Identity = u;
return ret;
}
}
return null;
}
private IdentityUser<string>? Map(BulstradAdIdentity? b)
{
if (b == null) return null;
return new IdentityUser
{
Id = string.Empty,
UserName = b.Username,
Email = b.Email,
NormalizedUserName = b.Username?.ToUpperInvariant(),
NormalizedEmail = b.Email?.ToUpperInvariant(),
EmailConfirmed = true
};
}
#endregion
}

View File

@@ -0,0 +1,120 @@
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Attributes;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Contracts;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Models;
using Microsoft.AspNetCore.Identity;
namespace Ablera.Serdica.Authority.Plugin.Ldap.Models;
/// <summary>
/// Stronglytyped projection of a Bulstrad AD user entry.
/// </summary>
public class BulstradAdIdentity : ILdapIdentity<string>
{
/* ────────────────────────── Core identification ────────────────────────── */
[LdapProperty("sAMAccountName")]
public required string Username { get; set; }
[LdapProperty("userPrincipalName")]
public string? Email { get; set; }
[LdapProperty("cn")]
public string? CommonName { get; set; }
[LdapProperty("givenName")]
public string? GivenName { get; set; }
[LdapProperty("sn")]
public string? Surname { get; set; }
/* ────────────────────────── Humanreadable info ────────────────────────── */
[LdapProperty("displayName")]
public string? DisplayName { get; set; }
[LdapProperty("description")]
public string? Description { get; set; }
[LdapProperty("info")]
public string? Info { get; set; } // freeform notes
/* ────────────────────────── DN & object identity ───────────────────────── */
public string? DistinguishedName { get; set; } // NOTE: This is populated by the Novel.LdapEntry.DN
/// </summary>
[LdapProperty("objectClass")]
public string[]? ObjectClasses { get; set; } // multivalued
[LdapProperty("objectGUID")]
public Guid? ObjectGuid { get; set; }
[LdapProperty("objectSid")]
public string? ObjectSid { get; set; }
[LdapProperty("objectCategory")]
public string? ObjectCategory { get; set; }
/* ────────────────────────── Group memberships ──────────────────────────── */
[LdapProperty("memberOf")]
public string[]? MemberOf { get; set; }
[LdapProperty("primaryGroupID")]
public int? PrimaryGroupId { get; set; }
/* ────────────────────────── Account state & counters ───────────────────── */
[LdapProperty("userAccountControl")]
public int? UserAccountControl { get; set; }
[LdapProperty("accountExpires")]
public long? AccountExpires { get; set; }
[LdapProperty("lockoutTime")]
public long? LockoutTime { get; set; }
[LdapProperty("badPwdCount")]
public int? BadPasswordCount { get; set; }
[LdapProperty("logonCount")]
public int? LogonCount { get; set; }
[LdapProperty("pwdLastSet")]
public long? PwdLastSet { get; set; }
[LdapProperty("lastLogon")]
public long? LastLogon { get; set; }
[LdapProperty("lastLogonTimestamp")]
public long? LastLogonTimestamp { get; set; }
[LdapProperty("lastLogoff")]
public long? LastLogoff { get; set; }
/* ────────────────────────── Audit / replication ────────────────────────── */
[LdapProperty("whenCreated")]
public DateTime? WhenCreated { get; set; }
[LdapProperty("whenChanged")]
public DateTime? WhenChanged { get; set; }
[LdapProperty("uSNCreated")]
public long? UsnCreated { get; set; }
[LdapProperty("uSNChanged")]
public long? UsnChanged { get; set; }
[LdapProperty("dSCorePropagationData")]
public string[]? DsCorePropagationData { get; set; }
/* ────────────────────────── Misc technical fields ──────────────────────── */
[LdapProperty("instanceType")]
public int? InstanceType { get; set; }
[LdapProperty("protocolSettings")]
public string[]? ProtocolSettings { get; set; }
[LdapProperty("msDS-SupportedEncryptionTypes")]
public int? SupportedEncryptionTypes { get; set; }
/* ────────────────────────── Infrastructure hooks ───────────────────────── */
public required IdentityUser<string> Identity { get; set; }
public required LdapSettings LdapSettings { get; set; }
}

View File

@@ -0,0 +1,23 @@
using Ablera.Serdica.Plugin.Contracts;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.Authority.Plugin.Bulstrad;
using Microsoft.AspNetCore.Identity;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Attributes;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Normalizers;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Contracts;
namespace Ablera.Serdica.Identity.Plugin.Bulstrad;
public class ServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
=> services
// Add Ldap plugin registrations
.AddSingleton<IEmailNormalizer, EmailNormalizer>()
.AddSingleton<IUsernameNormalizer, UsernameNormalizer>()
// Bulstrad plugin specific
.AddSingleton<BulstradAsLdapSettingsProvider>()
.AddScoped<BulstradAdIdentityFacade>()
.AddScoped<IUserManagementFacade<IdentityUser<string>>, Ablera.Serdica.Authority.Plugin.Bulstrad.IdentityManagementFacade>();
}

View File

@@ -0,0 +1,15 @@
[
{
"FriendlyName": "Bulstrad_AD",
"Url": "10.239.82.101",
"IsActiveDirectory": true,
"NormalizeEmailToDomain": "bulstrad.bg",
"Port": 3892,
"Ssl": false,
"DnTemplate": "CN={0},OU=_Ablera,OU=Regions,OU=_Bulstrad,DC=bulstrad,DC=bg",
"BindDn": "CN=Serdika,OU=_Ablera,OU=Regions,OU=_Bulstrad,DC=bulstrad,DC=bg",
"BindCredentials": "Ab123ra456",
"SearchBase": "OU=Regions,OU=_Bulstrad,DC=bulstrad,DC=bg",
"SearchFilter": "(&(objectClass=person)(|(userPrincipalName={0})(mail={0})))"
}
]

View File

@@ -0,0 +1,13 @@
###### generated-by: Ablera.Serdica.CiJobsBuilder 1.0.0 ######
FROM mirrors.ablera.dev/docker-mirror/dotnet/sdk:9.0-alpine AS build
WORKDIR /
COPY . .
WORKDIR /src/Serdica/Ablera.Serdica.Authority/__Plugins/Ablera.Serdica.Authority.Plugin.Ldap
RUN dotnet restore "Ablera.Serdica.Authority.Plugin.Ldap.csproj"
RUN dotnet publish "Ablera.Serdica.Authority.Plugin.Ldap.csproj" -c Release -o /app/PluginBinaries
RUN apk add --no-cache zip && \
cd / && zip -r /ablera-serdica-authority-plugin-ldap.zip app
FROM alpine:3.19 AS final
COPY --from=build /ablera-serdica-authority-plugin-ldap.zip /
LABEL org.opencontainers.image.description="Plugin artefacts only"

View File

@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
<!--
If the caller passes -p:CopyPluginBinariesToPath=… we respect that.
Otherwise we fall back to the local relative folder used in Visual Studio.
-->
<CopyPluginBinariesToPath Condition="'$(CopyPluginBinariesToPath)' == ''">$([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','Ablera.Serdica.Authority','PluginBinaries','$(MSBuildProjectName)'))</CopyPluginBinariesToPath>
</PropertyGroup>
<!-- Fires after **every** successful build, CLI or IDE -->
<Target Name="CopyPluginBinaries" AfterTargets="Build">
<!-- Every file that just landed in the target directory -->
<ItemGroup>
<BuiltFiles Include="$(TargetDir)**\*.*" />
</ItemGroup>
<Message Importance="high" Text="Copying files: @(BuiltFiles->'%(RecursiveDir)%(Filename)%(Extension)') &#xD;&#xA; to: $(CopyPluginBinariesToPath)" />
<Copy SourceFiles="@(BuiltFiles)" DestinationFolder="$(CopyPluginBinariesToPath)" SkipUnchangedFiles="true" />
</Target>
<ItemGroup>
<Content Include="ldap-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../../__Libraries/Ablera.Serdica.Authentication/Ablera.Serdica.Authentication.csproj" />
<ProjectReference Include="../../../../__Libraries/Ablera.Serdica.Common.Tools/Ablera.Serdica.Common.Tools.csproj" />
<ProjectReference Include="../../../../__Libraries/Ablera.Serdica.DBModels.Serdica/Ablera.Serdica.DBModels.Serdica.csproj" />
<ProjectReference Include="..\Ablera.Serdica.Authority.Plugins.Base\Ablera.Serdica.Authority.Plugins.Base.csproj" />
<ProjectReference Include="..\Ablera.Serdica.Authority.Plugins.LdapUtilities\Ablera.Serdica.Authority.Plugins.LdapUtilities.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,184 @@
using Ablera.Serdica.Common.Tools.Models.Config;
using Ablera.Serdica.Common.Tools;
using Ablera.Serdica.DBModels.Serdica;
using Ablera.Serdica.Authority.Plugins.Base.Contracts;
using Ablera.Serdica.Authority.Plugins.Base.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Ablera.Serdica.Common.Tools.Expressions.Models;
using Novell.Directory.Ldap;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Contracts;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Attributes;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Models;
namespace Ablera.Serdica.Authority.Plugin.Ldap;
/// <summary>
/// Thin façade that lets the generic pipeline work with <see cref="UserAccount"/> while delegating the real
/// work to an <see cref="IUserManager{OpenLdapAccount,string}"/> that talks to the directory.
/// </summary>
public class IdentityManagementFacade
: IUserManagementFacade<IdentityUser<string>>
{
private readonly LdapSettingsProvider ldapSettingsProvider;
private readonly LdapIdentityFacade userRepository;
public IdentityManagementFacade(
ILogger<GenericJsonSettingsProvider<Serdica.Extensions.Novell.Directory.Ldap.Models.LdapSettings[]>> logger,
IOptions<JsonFileSettingsConfig> options,
IUsernameNormalizer usernameNormalizer,
IEmailNormalizer emailNormalizer,
ILogger<LdapIdentityFacade> logger2,
ILogger<LdapIdentityFacade> logger3)
{
ldapSettingsProvider = new LdapSettingsProvider(
logger,
options);
userRepository = new LdapIdentityFacade(
logger3,
logger2,
ldapSettingsProvider,
emailNormalizer,
usernameNormalizer);
}
#region IAuthService
public async Task<AuthenticationResult> AuthenticateAsync(IdentityUser<string> identityUser,
string password,
bool lockoutOnFailure = false,
CancellationToken ct = default)
{
var ldap = await ResolveAsync(identityUser, ct);
if (ldap is null)
return AuthenticationResult.Fail("USER_NOT_FOUND");
return await userRepository.AuthenticateAsync(ldap, password, lockoutOnFailure, ct);
}
#endregion
#region IUserStore besteffort mapping
public async Task<IdentityUser<string>?> FindByEmailAsync(string email, CancellationToken ct = default)
=> (await userRepository.FindByEmailAsync(email, ct))?.Identity;
public async Task<IdentityUser<string>?> FindByNameAsync(string username, CancellationToken ct = default)
=> (await userRepository.FindByNameAsync(username, ct))?.Identity;
public async Task<IdentityUser<string>?> FindByIdAsync(string id, CancellationToken ct = default)
=> (await userRepository.FindByIdAsync(id, ct))?.Identity;
public async Task<OperationResult> CreateAsync(IdentityUser<string> identityUser, string password, CancellationToken ct = default)
{
if (identityUser == null) return OperationResult.Fail("NULL_USER");
var ldap = new LdapIdentity
{
Identity = identityUser,
Username = identityUser.UserName,
Email = identityUser.Email,
LdapSettings = ldapSettingsProvider.Settings.First()
};
return await userRepository.CreateAsync(ldap, password, ct);
}
public async Task<OperationResult> UpdateAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
if (identityUser == null) return OperationResult.Fail("NULL_USER");
var ldapIdentity = await ResolveAsync(identityUser, ct);
if (ldapIdentity == null) return OperationResult.Fail("USER_NOT_FOUND");
ldapIdentity.Username = identityUser.UserName;
ldapIdentity.Email = identityUser.Email;
return await userRepository.UpdateAsync(ldapIdentity, ct);
}
#endregion
#region Claim helpers
public async Task<IReadOnlyCollection<Claim>> GetBaseClaimsAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
var ldap = await ResolveAsync(identityUser, ct);
return ldap == null ? Array.Empty<Claim>() : await userRepository.GetBaseClaimsAsync(ldap, ct);
}
public async Task<IReadOnlyCollection<Claim>?> GetRolesClaimsAsync(IdentityUser<string> user, CancellationToken ct = default)
{
var ldap = await ResolveAsync(user, ct);
return ldap == null ? null : await userRepository.GetRolesClaimsAsync(ldap, ct);
}
#endregion
#region Password & lock operations delegated
public async Task<OperationResult> ChangePasswordAsync(IdentityUser<string> identityUser, string currentPassword, string newPassword, CancellationToken ct = default)
{
var ldap = await ResolveAsync(identityUser, ct);
return ldap == null
? OperationResult.Fail("USER_NOT_FOUND")
: await userRepository.ChangePasswordAsync(ldap, currentPassword, newPassword, ct);
}
public async Task<OperationResult> ResetPasswordAsync(IdentityUser<string> identityUser, string token, string newPassword, CancellationToken ct = default)
{
var ldap = await ResolveAsync(identityUser, ct);
return ldap == null
? OperationResult.Fail("USER_NOT_FOUND")
: await userRepository.ResetPasswordAsync(ldap, token, newPassword, ct);
}
public async Task<OperationResult> LockAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
var ldap = await ResolveAsync(identityUser, ct);
return ldap == null ? OperationResult.Fail("USER_NOT_FOUND") : await userRepository.LockAsync(ldap, ct);
}
public async Task<OperationResult> UnlockAsync(IdentityUser<string> identityUser, CancellationToken ct = default)
{
var ldap = await ResolveAsync(identityUser, ct);
return ldap == null ? OperationResult.Fail("USER_NOT_FOUND") : await userRepository.UnlockAsync(ldap, ct);
}
#endregion
#region Helpers
private async Task<LdapIdentity?> ResolveAsync(IdentityUser<string> u, CancellationToken ct)
{
if (u == null) return null;
if (string.IsNullOrWhiteSpace(u.Email) == false)
{
var ret = await userRepository.FindByEmailAsync(u.Email, ct);
if (ret != null)
{
ret.Identity = u;
return ret;
}
}
if (string.IsNullOrWhiteSpace(u.UserName) == false)
{
var ret = await userRepository.FindByNameAsync(u.UserName, ct);
if (ret != null)
{
ret.Identity = u;
return ret;
}
}
return null;
}
#endregion
}

View File

@@ -0,0 +1,58 @@
using Ablera.Serdica.Authority.Plugins.Base.Models;
using Ablera.Serdica.Common.Tools.Extensions;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Attributes;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Contracts;
using Ablera.Serdica.Extensions.Novell.Directory.Ldap.Models;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
using Ablera.Serdica.Authority.Plugins.LdapUtilities.Services;
namespace Ablera.Serdica.Authority.Plugin.Ldap;
public sealed class LdapIdentityFacade : LdapIdentityFacadeBase<LdapIdentity, string>
{
private readonly ILogger<LdapIdentityFacade> logger;
public LdapIdentityFacade(
ILogger<LdapIdentityFacade> logger,
ILogger<LdapIdentityFacadeBase<LdapIdentity, string>> logger2,
LdapSettingsProvider ldapSettingsProvider,
IEmailNormalizer emailNormalizer,
IUsernameNormalizer usernameNormalizer)
: base(logger2, ldapSettingsProvider, emailNormalizer, usernameNormalizer)
{
this.logger = logger;
}
public override async Task<AuthenticationResult> AuthenticateAsync(LdapIdentity user, string password, bool lockoutOnFailure = false, CancellationToken ct = default)
{
var result = await base.AuthenticateAsync(user, password, lockoutOnFailure, ct);
if (result.Succeeded == false) return result;
if (string.IsNullOrWhiteSpace(password))
return AuthenticationResult.Fail(AuthenticationCode.EmptyCredentials.ToScreamingSnakeCase());
if (string.IsNullOrWhiteSpace(user.DistinguishedName))
return AuthenticationResult.Fail(AuthenticationCode.AccountIsNotAuthenticaAble.ToScreamingSnakeCase());
// Ensure account is active.
if (user.BulstradAccountStatus != null && !string.Equals(user.BulstradAccountStatus, "active", StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("Bulstrad account {User} is not active (bstDStatus={Status})", user.Username, user.BulstradAccountStatus);
return AuthenticationResult.Fail(AuthenticationCode.AccountIsNotActive.ToScreamingSnakeCase());
}
// Build extra claims and append to principal
var extraClaims = new[]
{
new Claim("bstRole", user.BulstradRole ?? string.Empty),
new Claim("bstContractId", user.BulstradContractId ?? string.Empty),
new Claim("bstManId", user.BulstradManId ?? string.Empty),
new Claim(ClaimTypes.GroupSid, user.BulstradDepartmentNumber ?? string.Empty)
};
return result;
}
}

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