diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index cb233ab5d..87a26ce65 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -42,6 +42,9 @@ jobs:
dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.CdsHooks.Model/Udap.CdsHooks.Model.csproj
dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Model/Udap.Tefca.Model.csproj
+ dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Server/Udap.Tefca.Server.csproj
+
+ dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Ssraa.Server/Udap.Ssraa.Server.csproj
- name: Push
run: |
@@ -61,3 +64,6 @@ jobs:
dotnet nuget push ./Udap.CdsHooks.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
dotnet nuget push ./Udap.Tefca.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
+ dotnet nuget push ./Udap.Tefca.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
+
+ dotnet nuget push ./Udap.Ssraa.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f90b9789c..d6a6c489e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -42,6 +42,9 @@ jobs:
dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.CdsHooks.Model/Udap.CdsHooks.Model.csproj
dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Model/Udap.Tefca.Model.csproj
+ dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Tefca.Server/Udap.Tefca.Server.csproj
+
+ dotnet pack -v normal -c Release --include-source -p:PackageVersion=${VERSION} ./Udap.Ssraa.Server/Udap.Ssraa.Server.csproj
- name: Push
run: |
@@ -61,6 +64,9 @@ jobs:
dotnet nuget push ./Udap.CdsHooks.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
dotnet nuget push ./Udap.Tefca.Model/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
-
-
+ dotnet nuget push ./Udap.Tefca.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
+
+ dotnet nuget push ./Udap.Ssraa.Server/bin/Release/*.symbols.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
+
+
diff --git a/.gitignore b/.gitignore
index 2a09e440d..0be514ec9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -226,3 +226,4 @@ examples/clients/UdapEd/Client/wwwroot/temp/MudBlazor.min.css
/examples/Udap.Proxy.Server/CertStore/issued/gfhirlabs.healthcare.client.pfx
/coverage-results
/.claude
+docs/specifications/SSRAA/V2.0.0/ssraa_full-ig_v2.0.0.zip
diff --git a/CLAUDE.md b/CLAUDE.md
index 10ad3482c..a9505064a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -113,6 +113,7 @@ builder.Services.AddAuthentication().AddTieredOAuth(options => { ... });
- **Udap.Proxy.Server** - YARP-based reverse proxy to secure existing FHIR servers with UDAP
- **Udap.Identity.Provider / Udap.Identity.Provider.2** - Tiered OAuth IdP examples
- **Udap.CA** - Web UI for generating UDAP certificates
+- **Sigil** (`examples/CA/`) - Modern PKI management tool (Blazor Server + FluentUI v4 + PostgreSQL). Three projects: `Sigil` (host), `Sigil.Common` (services/entities, no UI deps), `Sigil.UI` (Razor Class Library). See [`examples/CA/Sigil/docs/FEATURES.md`](examples/CA/Sigil/docs/FEATURES.md) for full feature list and [`examples/CA/ROADMAP.md`](examples/CA/ROADMAP.md) for planned phases.
### Test Projects (`/_tests`)
@@ -134,3 +135,7 @@ builder.Services.AddAuthentication().AddTieredOAuth(options => { ... });
- **YARP 2.1.0** - Reverse proxy (for proxy examples)
Package versions are centrally managed in `Directory.Packages.props`.
+
+## Specifications
+
+- **UDAP.org** is the base specification for this SDK. All 7 specs are in `docs/specifications/UDAP.org/` (see `README.md` there for index). SSRAA and TEFCA are profiles layered on top of UDAP.org.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c5000e7b5..04480987c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -34,6 +34,9 @@
+
+
+
@@ -43,19 +46,26 @@
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -82,12 +92,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Dockerfile.gcp b/Dockerfile.gcp
index 96d6f76bf..f804225d0 100644
--- a/Dockerfile.gcp
+++ b/Dockerfile.gcp
@@ -2,7 +2,7 @@
# docker build -t udap-gcp -f Dockerfile.gcp .
#
# Run interactively (first time — creates named volume for gcloud creds):
-# docker run -it --name udap-gcp -v udap-gcloud-config:/root/.config/gcloud udap-gcp
+# docker run -it --name udap-gcp -v udap-gcloud-config:/root/.config/gcloud -v C:\temp\_GCP:/output udap-gcp
#
# Re-enter after stopping:
# docker start -ai udap-gcp
@@ -52,6 +52,9 @@ COPY ["Udap.Smart.Model/Udap.Smart.Model.csproj", "Udap.Smart.Model/"]
COPY ["Udap.Smart.Metadata/Udap.Smart.Metadata.csproj", "Udap.Smart.Metadata/"]
COPY ["Udap.CdsHooks.Model/Udap.CdsHooks.Model.csproj", "Udap.CdsHooks.Model/"]
COPY ["Udap.CdsHooks.Endpoint/Udap.CdsHooks.Endpoint.csproj", "Udap.CdsHooks.Endpoint/"]
+COPY ["Udap.Tefca.Model/Udap.Tefca.Model.csproj", "Udap.Tefca.Model/"]
+COPY ["Udap.Tefca.Server/Udap.Tefca.Server.csproj", "Udap.Tefca.Server/"]
+COPY ["Udap.Ssraa.Server/Udap.Ssraa.Server.csproj", "Udap.Ssraa.Server/"]
# -- Examples --
COPY ["examples/FhirLabsApi/FhirLabsApi.csproj", "examples/FhirLabsApi/"]
COPY ["examples/Udap.Auth.Server/Udap.Auth.Server.csproj", "examples/Udap.Auth.Server/"]
diff --git a/README.md b/README.md
index db2e6769c..8c80588c8 100644
--- a/README.md
+++ b/README.md
@@ -1,119 +1,246 @@
-# udap-dotnet
+
+
+
-UDAP SDK and reference implementation for .NET.
+udap-dotnet
-In short UDAP is a PKI extension profile to OAuth2. One or more PKIs can be hosted by a `Community`. Joining a `Community` results in a public/private key issued to a client. The client also chooses to explicitly trust one of the issuing certificates in that chain by installing in your client. In addition, all certificate chain validation including certificate revocation to a trusted root are performed.
+
+ UDAP SDK and reference implementation for .NET
+
-Note: This is a new project. It will take me some time to document. It should be very active in code changes and document additions. But feel free to try it out and add issues and/or pull requests.
+
+
+
+
+
+
-Many example apps are in the examples folder.
+---
-- FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7.
-- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. UDAP Draft Specifications are referenced and displayed in parts of this source code to document specification implementation.
+> **Note:** Active development happens on the [`develop`](https://github.com/JoeShook/udap-dotnet/tree/develop) branch. The `main` branch contains the latest stable release.
-## What does it support
+---
-The repository contains components and example uses to support the following items from [Security for Scalable Registration, Authentication, and Authorization](http://hl7.org/fhir/us/udap-security/). The intent is to also support generic UDAP, but the driving force currently is supporting auto registration to FHIR® servers.
+UDAP is a PKI extension profile to OAuth 2.0. One or more PKIs can be hosted by a **Community**. Joining a community results in a public/private key issued to a client. The client explicitly trusts one of the issuing certificates in that chain. Full certificate chain validation including certificate revocation to a trusted root is performed.
-| Feature | Sub Feature | Supported | Comments |
-|-------------------------|---|---------------------|--------------------------------------------------------|
-| Udap.Client ||||
-||UDAP Metadata Validation|✔️| Validates JWT and Certificates. See [Udap.Client](Udap.Client/docs/README.md) for usage. |
-||Dynamic Client Registration|✔️| Functionally DCR exists but it has not been packaged and documented in Udap.Client package.|
-||Access Token |✔️| Functionally exists and needs to be packaged and documented in Udap.Client packages |
-||[hl7-b2b extension](http://hl7.org/fhir/us/udap-security/b2b.html#b2b-authorization-extension-object)|In process|This is hard coded in the UdapEd tool for illustration and to pass registration against Authorization Servers that require it. It is a required claim when requesting an access token in the client_credentials grant type flow profiles by UDAP Security under HL7 FHIR. I don't know if it stays here as a feature yet. I do want to call it out because it is a very meaningful feature of UDAP in the HL7 FHIR use case. |
-| [Discovery](http://hl7.org/fhir/us/udap-security/discovery.html): UDAP Metadata for Resource Server||||
-| | Udap.Metadata.Server | ✔️ Including [Multi Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities) | Certificate storage is a file strategy. User can implement their own ICertificateStore. May add a Entity Framework example and/or HSM in the future. Checkout the [2023 FHIR® DevDays Tutorial](udap-devdays-2023) to see it in action and the [Udap.Metadata.Server docs](https://github.com/JoeShook/Udap.Metadata.Vonk.Server/tree/develop) |
-|| Udap.Metadata.Vonk.Server | Trial status. Including [Multi Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities) | This is based on the same components that build ```Udap.Metadata.Server```. It can be used as a plugin for the Firely server. It has been tested on the Community edition. Readme more in the [docs](https://github.com/JoeShook/Udap.Metadata.Vonk.Server/tree/develop)|
-| [Server Dynamic Registration](http://hl7.org/fhir/us/udap-security/registration.html)|| ✔️ Including [Multi Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities). | Highly Functional. The Deployed example FHIR® Server, "FhirLabsApi" is passing all udap.org Server Tests. I am going to revisit the Client Secrets persistence layer. Packages are dependent on Duende's Identity Server Nuget Packages. ✔️ Registration with 201 created ✔️ Registration with 200 updated ✔️ Cancel registration with 200 ✔️ Cancel registration with 404 not found Notes: Since this development, the Identity Server has Implemented Dynamic Registration. We could revisit this and try to enable UDAP under the new DCR feature. |
-||Inclusion of Certifications and Endorsements|Started|Some example certification integration tests included from the client side |
-Authorization and Authentication
-| [Consumer-Facing](http://hl7.org/fhir/us/udap-security/consumer.html)|| ✔️ | Functionality same as B2B authorization_code flow. Client would typically register and or request user/* prefixed scopes |
-| [Business-to-Business](http://hl7.org/fhir/us/udap-security/b2b.html)|| ✔️ | Works with client_credentials and authorization_code flows. |
-||JWT Claim Extensions|Started|Some work completed for the B2B Authorization Extension (hl7-b2b) extension within integration tests. }
-| [Tiered OAuth for User Authentication](http://hl7.org/fhir/us/udap-security/user.html) || ✔️ | Simply register Tiered OAuth functionality with the code snippet below. There is a good integration test called [ClientAuthorize_IdPDiscovery_IdPRegistration_IdPAuthAccess_ClientAuthAccess_Test](/_tests/UdapServer.Tests/Conformance/Tiered/TieredOauthTests.cs). This spins up two in memory instances of Identity Server. One plays the role of Authorization Server and the other plays the role of Identity Provider. This test harness is important to quickly test Tiered OAuth without a user interface. When I built this first implementation, I was inspired by other implementations such as this great collection from the [aspnet-contrib](https://github.com/aspnet-contrib) organization, called [AspNet.Security.OpenId.Providers](https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers). There is another repository at this organization, called [AspNet.Security.OpenId.Providers](https://github.com/aspnet-contrib/AspNet.Security.OpenId.Providers). |
+> - FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7.
+> - UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org.
-```csharp
-builder.Services.AddAuthentication()
- .AddTieredOAuth(options =>
- {
- options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
- });
-```
+---
-## PKI support
-### Generate PKI for integration tests
+## Specifications Supported
-Part of this repository is a xUnit test project that will generate a couple PKI hierarchies for testing UDAP. The test is called `Udap.PKI.Generator`. I think showing the mechanics of what it takes to build out a PKI for UDAP will aid education and provide the flexibility to test interesting use cases. Run all the tests in the `Udap.PKI.Generator` project. The results include a folder with root a root certificate authority that issues intermediate certificates, certificate revocation lists, used certificates for community members and certs for web TLS certs. Each of the example web services located in the [examples](/examples) use MSBuild `Link`s to link to certificates appropriate to its PKI needs. So, if you would like to change something in the PKI just edit and run the tests. All examples will automatically pick up the changes. To enable crl lookup and AIA, Certification Authority Issuer resolution I just mapped crl, cert and anchor as static content via something like IIS on my Windows box. I may create a dotnet core app to make this easier and it into ci/cd better but this is where I am at so
-I am not sure if this will stay in unit test form or not, but for now this is the technique.
+| Specification | Status | Description |
+|:---|:---:|:---|
+| [UDAP.org](https://www.udap.org/) |  | Base UDAP specs — server metadata, DCR, JWT client auth, authorization grants, tiered OAuth, C&E, TLS client auth. All 7 specs in [`docs/specifications/UDAP.org/`](docs/specifications/UDAP.org/) |
+| [HL7 FHIR UDAP Security IG (SSRAA)](http://hl7.org/fhir/us/udap-security/) |  | Security for Scalable Registration, Authentication, and Authorization |
+| [TEFCA Facilitated FHIR](https://rce.sequoiaproject.org/) |  | Trusted Exchange Framework and Common Agreement. B2B flows, XP code validation, IAS extensions. Specs in [`docs/specifications/TEFCA/`](docs/specifications/TEFCA/) |
-### Certificate Authority tool
+---
-A .NET UI and CLI tool to generate certificates for UDAP communities. A UI version of this tool is partially done in the [Udap.CA](/examples/Udap.CA/) project. The plan is to deploy this or install it yourself and allow quick generations of certificates and PKI hierarchies that can generate valid and various invalid certificates for testing to aid in experimenting with behaviors such as certificate revocation, expirations and other interesting potential certification use cases.
+## Features at a Glance
-## Components (Nuget packages)
+
+Discovery & Registration
-See the following Udap.Metadata.Server and Udap.Server sections. The Udap.Metadata.Server is for the resource server such a FHIR® Server. Udap.Server.Server is for the Identity Server.
+| Feature | Status |
+|:---|:---:|
+| `.well-known/udap` metadata endpoint | :white_check_mark: |
+| [Multiple Trust Communities](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities) | :white_check_mark: |
+| [Multi-Domain Metadata](./docs/multi-domain-metadata.md) — dynamic cert selection by request URL | :white_check_mark: |
+| Metadata JWT & certificate chain validation | :white_check_mark: |
+| [Dynamic Client Registration](http://hl7.org/fhir/us/udap-security/registration.html) (create / update / cancel) | :white_check_mark: |
+| [Certifications & Endorsements](https://www.udap.org/udap-certifications-and-endorsements-stu1.html) | :white_check_mark: |
-## Udap.Metadata.Server
+
-Follow the [Udap.Metadata.Server docs](./Udap.Metadata.Server/docs/README.md) for configuring your resource server.
+
+Authorization & Authentication
-For hosting multiple domains with different certificates under a single community, see [Multi-Domain Metadata Support](./docs/multi-domain-metadata.md).
+| Feature | Status |
+|:---|:---:|
+| [Consumer-Facing](http://hl7.org/fhir/us/udap-security/consumer.html) (authorization_code) | :white_check_mark: |
+| [Business-to-Business](http://hl7.org/fhir/us/udap-security/b2b.html) (client_credentials) | :white_check_mark: |
+| [Tiered OAuth](http://hl7.org/fhir/us/udap-security/user.html) — federated user authentication | :white_check_mark: |
-## Udap.Client
+
-Follow the [Udap.Client docs](./Udap.Client/docs/README.md) for configuring your UDAP client.
+
+Authorization Extension Objects (AEOs)
-## Udap.Server
+| Extension | Spec | Status | Description |
+|:---|:---|:---:|:---|
+| `hl7-b2b` | SSRAA / TEFCA | :white_check_mark: | B2B extension for client_credentials — purpose_of_use enforcement |
+| `hl7-b2b-user` | SSRAA | :white_check_mark: | B2B extension for authorization_code with user context (FHIR Person) |
+| `tefca-ias` | TEFCA | :white_check_mark: | Individual Access Services — patient/user info, consent, id_token |
+| `tefca_smart` | TEFCA | :black_square_button: | Constants defined; model and validation not yet implemented |
-Follow the [Udap.Server docs](./Udap.Server/docs/README.md) for configuring your UDAP client.
+**Pluggable validation** via `IUdapAuthorizationExtensionValidator` with per-community rules:
+- **SSRAA** — validates against HL7 v3 PurposeOfUse value set (60+ codes)
+- **TEFCA** — validates against 12 TEFCA Exchange Purpose (XP) codes, enforces single purpose_of_use, SAN URI matching
-## Build and test
+
-From root.
+
+TEFCA-Specific Features
-```csharp
-dotnet restore
-```
+| Feature | Status |
+|:---|:---:|
+| Exchange Purpose validation (all 12 XP codes) | :white_check_mark: |
+| SAN URI exchange purpose matching | :white_check_mark: |
+| Organization ID validation (RCE Directory format) | :white_check_mark: |
+| TEFCA Authorization Error extension (consent_required) | :white_check_mark: |
+| IAS flow via `tefca-ias` extension | :white_check_mark: |
-If this is first build or you want to reset you certificates change to /_tests/Udap.PKI.Generator. This must be done once. Other projects are dependent on a lab environment with test PKIs. This is good in that the development experience will always have PKI structures that do not contain expired certificates unless that is an intended artifact of the data set.
+
-```csharp
-dotnet test
+---
+
+## NuGet Packages
+
+### Core
+
+| Package | Description |
+|:---|:---|
+| [](https://www.nuget.org/packages/Udap.Model) | Data models and constants (zero external dependencies) |
+| [](https://www.nuget.org/packages/Udap.Common) | Certificate & trust chain validation, `ICertificateStore`, `ITrustAnchorStore` |
+| [](https://www.nuget.org/packages/Udap.Client) | Discovery, registration, token requests via `IUdapClient` |
+| [](https://www.nuget.org/packages/Udap.Metadata.Server) | `.well-known/udap` endpoint for resource servers |
+| [](https://www.nuget.org/packages/Udap.Server) | Authorization server extensions (Duende IdentityServer), DCR |
+| [](https://www.nuget.org/packages/Udap.Server.Storage) | EF Core persistence (SQLite, SQL Server, PostgreSQL) |
+| [](https://www.nuget.org/packages/Udap.TieredOAuth) | Federated OAuth / external IdP integration |
+
+### Profile-Specific
+
+| Package | Description |
+|:---|:---|
+| [](https://www.nuget.org/packages/Udap.Ssraa.Server) | SSRAA community validation (purpose_of_use value set, required extensions) |
+| [](https://www.nuget.org/packages/Udap.Tefca.Model) | TEFCA extension models (`tefca-ias`, `tefca_smart`, XP constants) |
+| [](https://www.nuget.org/packages/Udap.Tefca.Server) | TEFCA community validation (XP codes, SAN matching) |
+
+### Configuration Docs
+
+- **Resource Server** — [Udap.Metadata.Server docs](./Udap.Metadata.Server/docs/README.md) | [Multi-domain metadata](./docs/multi-domain-metadata.md)
+- **Client** — [Udap.Client docs](./Udap.Client/docs/README.md)
+- **Authorization Server** — [Udap.Server docs](./Udap.Server/docs/README.md)
+
+---
+
+## Examples
+
+See the [`examples/`](./examples) folder. Full list below.
+
+
+Servers
+
+| Project | Description |
+|:---|:---|
+| [FhirLabsApi](./examples/FhirLabsApi/) | FHIR R4B resource server — passes all [udap.org](https://udap.org) conformance tests |
+| [Udap.Auth.Server](./examples/Udap.Auth.Server/) | Authorization server with Duende IdentityServer + UDAP |
+| [Udap.Proxy.Server](./examples/Udap.Proxy.Server/) | YARP reverse proxy — add UDAP security to existing FHIR servers |
+| [Tefca.Proxy.Server](./examples/Tefca.Proxy.Server/) | TEFCA-configured reverse proxy |
+
+
+
+
+Identity Providers
+
+| Project | Description |
+|:---|:---|
+| [Udap.Identity.Provider](./examples/Udap.Identity.Provider/) | Tiered OAuth IdP |
+| [Udap.Identity.Provider.2](./examples/Udap.Identity.Provider.2/) | Second IdP for federation testing |
+
+
+
+
+Admin & Tooling
+
+| Project | Description |
+|:---|:---|
+| [Udap.Auth.Server.Admin](./examples/Auth.Server.Admin/) | Admin UI for UDAP tables |
+| [Udap.Pki.Cli](./examples/Udap.Pki.Cli/) | CLI tool for PKI operations |
+| [UdapEd](https://github.com/JoeShook/UdapEd) | UDAP testing and exploration tool *(separate repository)* |
+
+
+
+
+Sigil — PKI Management Tool
+
+[**Sigil**](./examples/CA/) is a modern certificate authority and PKI management tool built with .NET, Blazor Server, FluentUI v4, and PostgreSQL.
+
+:arrow_right: [Full feature list](./examples/CA/Sigil/docs/FEATURES.md) | [Roadmap](./examples/CA/ROADMAP.md)
+
+| Capability | Details |
+|:---|:---|
+| **Certificate Explorer** | Hierarchical tree view, color-coded status badges, chain validation, ASN.1 viewer |
+| **Certificate Issuance** | Configurable templates (Root CA, Intermediate CA, UDAP Client, SSL Server), RSA & ECDSA |
+| **Certificate Lifecycle** | Import (drag & drop, batch), renewal (re-key / re-sign), archive, revocation |
+| **CRL Management** | Import, online resolution via CDP, revocation status tracking |
+| **Remote Signing** | Pluggable `ISigningProvider` — HashiCorp Vault Transit and Google Cloud KMS |
+| **Aspire Orchestration** | Dev / Docker / GCP launch profiles via `Sigil.AppHost` |
+
+
+
+---
+
+## Getting Started
+
+### Build
+
+```bash
+dotnet restore
+dotnet test _tests/Udap.PKI.Generator # Generate test PKI (required once)
+dotnet build Udap.sln
```
-Return to root
+### Test
-```csharp
-dotnet build
+```bash
+dotnet test _tests/Udap.Common.Tests
+dotnet test _tests/UdapMetadata.Tests
+dotnet test _tests/UdapServer.Tests
```
-### Running tests
+> **Tip:** Avoid `Udap.Client.System.Tests` in CI — those test against live servers.
+> If SQLite DB sync issues occur, clean the `bin` folder in affected test projects.
-Again it is probably best to avoid running Udap.PKI.Generator unless you need the certificates regenerated. I may migrate this away from unit test in future. Or create a src folder to isolate.
-It is also best to avoid Udap.Client.System.Tests as they are for experimenting with live servers. Eventually the [FhirLabs UdapEd client tool](/examples/clients/UdapEd/Server/) will replace the need for this.
+### Run Examples Locally
-The following tests are normal to run and the build server runs these same tests.
+```bash
+# Install Tye (one-time)
+dotnet tool install -g Microsoft.Tye --version "0.12.0-*" \
+ --add-source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet6/nuget/v3/index.json
-- [Udap.CA.Tests](./_tests/Udap.CA.Tests/)
-- [Udap.Common.Tests](./_tests/Udap.Common.Tests/)
-- [Udap.Support.Tests](./_tests/Udap.Support.Tests/)
-- [UdapMetadata.Tests](./_tests/UdapMetadata.Tests/), tests two against the two example web services, FhirLabsApi and WeatherApi.
-- [UdapServer.Tests](./_tests/UdapServer.Tests/). There are times when the bin folder should be deleted because the SQLite DB gets out of sync with the PKI artifacts because the Udap.PKI.Generator tests were ran after the SQLite database is created.
+# Start all services with hot reload
+tye run --watch
+```
-## Other examples
+### Quick Start — Tiered OAuth
-### UDAP Admin UI Tool
+```csharp
+builder.Services.AddAuthentication()
+ .AddTieredOAuth(options =>
+ {
+ options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
+ });
+```
-This is a MVP version of an Admin UI Tool. It is only capable of administering the Udap prefixed tables.
+---
-See [Udap.Auth.Server.Admin](./examples/Auth.Server.Admin/)
+## Database Migrations
-### UDAP CA UI Tool
+| Project | Target |
+|:---|:---|
+| [UdapDb.SqlServer](./migrations/UdapDb.SqlServer/) | SQL Server |
+| [UdapDb.Postgres](./migrations/UdapDb.Postgres/) | PostgreSQL |
-This is barely implemented. The spirit of it is to create a easy to use CA for experimenting in a lab environment. At this point all the tooling for creating interesting PKI test data for success and failure use cases lives in the Udap.PKI.Generator test project.
+## Key Dependencies
-### UDAP Proxy Server
+| Package | Purpose |
+|:---|:---|
+| [Duende.IdentityServer](https://duendesoftware.com/) | Identity & auth platform |
+| [BouncyCastle.Cryptography](https://www.bouncycastle.org/csharp/) | X.509 PKI operations |
+| [Hl7.Fhir.R4B](https://fire.ly/products/firely-net-sdk/) | FHIR models |
+| [YARP](https://microsoft.github.io/reverse-proxy/) | Reverse proxy |
-Turn your FHIR server into a UDAP secured FHIR server with [Udap.Proxy.Server](/examples/Udap.Proxy.Server).
+Versions centrally managed in [`Directory.Packages.props`](./Directory.Packages.props).
diff --git a/Udap.CdsHooks.Endpoint/docs/README.md b/Udap.CdsHooks.Endpoint/docs/README.md
index 068845019..143c9c960 100644
--- a/Udap.CdsHooks.Endpoint/docs/README.md
+++ b/Udap.CdsHooks.Endpoint/docs/README.md
@@ -2,6 +2,6 @@

-## 📦 This package
-
+## 📦 Nuget Package: [Udap.CdsHooks.Endpoint](https://www.nuget.org/packages/Udap.CdsHooks.Endpoint)
+Server-side endpoint implementation for [CDS Hooks](https://cds-hooks.org/) services. Handles GET requests for service discovery and POST requests for hook invocations, processing CDS requests and returning recommendation cards.
diff --git a/Udap.CdsHooks.Model/docs/README.md b/Udap.CdsHooks.Model/docs/README.md
index f1869a73b..48f1014d0 100644
--- a/Udap.CdsHooks.Model/docs/README.md
+++ b/Udap.CdsHooks.Model/docs/README.md
@@ -2,6 +2,6 @@

-## 📦 This package
-
+## 📦 Nuget Package: [Udap.CdsHooks.Model](https://www.nuget.org/packages/Udap.CdsHooks.Model)
+Data models for the [CDS Hooks](https://cds-hooks.org/) specification. Includes `CdsServices`, `CdsCard`, `CdsRequest`, `CdsResponse`, `CdsAction`, and related types for implementing Clinical Decision Support services.
diff --git a/Udap.Client/docs/README.md b/Udap.Client/docs/README.md
index d71725fc0..c7c5ae084 100644
--- a/Udap.Client/docs/README.md
+++ b/Udap.Client/docs/README.md
@@ -172,12 +172,9 @@ services.AddScoped(sp => new TrustChainValidator(new X509Ch
services.AddHttpClient();
```
-TODO: Cover X509ChainPolicy
-
-
## Udap.Client Dynamic Client Registration with a ICertificateStore implementation
### Example projects
-- [Udap.Config simple dependency injection](./examples/clients/1_UdapClientMetadata/README.md)
-- [Udap.Client configuration with a ITrustAnchorStore implementation](./examples/clients/2_UdapClientMetadata/README.md)
+- [Udap.Config simple dependency injection](../../examples/clients/1_UdapClientMetadata/README.md)
+- [Udap.Client configuration with a ITrustAnchorStore implementation](../../examples/clients/2_UdapClientMetadata/README.md)
diff --git a/Udap.Common/Certificates/ChainElementInfo.cs b/Udap.Common/Certificates/ChainElementInfo.cs
index 2a903d27d..97879dc60 100644
--- a/Udap.Common/Certificates/ChainElementInfo.cs
+++ b/Udap.Common/Certificates/ChainElementInfo.cs
@@ -120,11 +120,12 @@ public class ChainValidationResult
/// Whether the chain is valid and trusted.
/// The chain elements with their validation problems.
/// The community identifier from the matching trust anchor, if resolved.
- public ChainValidationResult(bool isValid, IReadOnlyList chainElements, long? communityId = null)
+ public ChainValidationResult(bool isValid, IReadOnlyList chainElements, long? communityId = null, string? communityName = null)
{
IsValid = isValid;
ChainElements = chainElements;
CommunityId = communityId;
+ CommunityName = communityName;
}
/// Gets whether the certificate chain is valid and trusted.
@@ -135,4 +136,7 @@ public ChainValidationResult(bool isValid, IReadOnlyList chain
/// Gets the community identifier from the matching trust anchor, or null if not resolved.
public long? CommunityId { get; }
+
+ /// Gets the community name (URI) from the matching trust anchor, or null if not resolved.
+ public string? CommunityName { get; }
}
diff --git a/Udap.Common/Certificates/TrustChainValidator.cs b/Udap.Common/Certificates/TrustChainValidator.cs
index 529c33eb2..6d0017a8a 100644
--- a/Udap.Common/Certificates/TrustChainValidator.cs
+++ b/Udap.Common/Certificates/TrustChainValidator.cs
@@ -231,6 +231,7 @@ public async Task IsTrustedCertificateAsync(
var chainElements = new List();
bool foundAnchor = false;
long? communityId = null;
+ string? communityName = null;
bool chainValid = true;
for (int i = 0; i < bcChain.Count; i++)
@@ -282,6 +283,7 @@ public async Task IsTrustedCertificateAsync(
if (matchingAnchor != null)
{
communityId = matchingAnchor.CommunityId;
+ communityName = matchingAnchor.Community;
}
}
}
@@ -326,7 +328,7 @@ public async Task IsTrustedCertificateAsync(
NotifyUntrusted(certificate);
}
- return new ChainValidationResult(isValid, chainElements, communityId);
+ return new ChainValidationResult(isValid, chainElements, communityId, communityName);
}
catch (Exception ex)
{
diff --git a/Udap.Common/Metadata/UdapMetaDataBuilder.cs b/Udap.Common/Metadata/UdapMetaDataBuilder.cs
index eb98c3d11..5172c9c8d 100644
--- a/Udap.Common/Metadata/UdapMetaDataBuilder.cs
+++ b/Udap.Common/Metadata/UdapMetaDataBuilder.cs
@@ -169,6 +169,16 @@ public async Task GetCommunitiesAsHtml(string path, CancellationToken to
udapMetaData.TokenEndpointAuthSigningAlgValuesSupported = udapMetadataConfig.SignedMetadataConfig.TokenSigningAlgorithms;
}
+ if (udapMetadataConfig.UdapCertificationsSupported != null && udapMetadataConfig.UdapCertificationsSupported.Count != 0)
+ {
+ udapMetaData.UdapCertificationsSupported = udapMetadataConfig.UdapCertificationsSupported;
+ }
+
+ if (udapMetadataConfig.UdapCertificationsRequired != null && udapMetadataConfig.UdapCertificationsRequired.Count != 0)
+ {
+ udapMetaData.UdapCertificationsRequired = udapMetadataConfig.UdapCertificationsRequired;
+ }
+
var certificate = await Load(udapMetadataConfig, baseUrl, token);
if (certificate == null)
diff --git a/Udap.Custom.TrustStore/docs/README.md b/Udap.Custom.TrustStore/docs/README.md
index 663400ce1..23f7da8cb 100644
--- a/Udap.Custom.TrustStore/docs/README.md
+++ b/Udap.Custom.TrustStore/docs/README.md
@@ -4,6 +4,6 @@
## 📦 This package
-This package would be used if your target OS is Windows and you cannot load certificates into the Windows Certificate Store. Currently this package is not used by in any code or examples. It will most likely be removed in favor of new abilities in dotnet core that allow us to load our own custom trust store.
+> **Note:** This is an internal package and is not published to NuGet.
-Actually this is not a published package.
+This package provides an alternative trust store implementation for Windows environments where certificates cannot be loaded into the Windows Certificate Store. It may be superseded by newer .NET capabilities for loading custom trust stores.
diff --git a/Udap.Metadata.Server/docs/README.md b/Udap.Metadata.Server/docs/README.md
index 3066cf025..5fb49b02a 100644
--- a/Udap.Metadata.Server/docs/README.md
+++ b/Udap.Metadata.Server/docs/README.md
@@ -2,69 +2,60 @@

-## 📦 Nuget Package: [Udap.Client](https://www.nuget.org/packages?q=udap.metadata.server)
+## 📦 Nuget Package: [Udap.Metadata.Server](https://www.nuget.org/packages/Udap.Metadata.Server)
-This package includes a MVC controller, an extension method to load, and an implementation if `ICertificateStore` as `FileCertificateStore` so you can get a sample up and running quickly.
+This package provides the `.well-known/udap` metadata endpoint for FHIR resource servers and other UDAP-secured APIs. It includes an extension method for service registration, middleware for dynamic metadata serving, and a built-in `FileCertificateStore` implementation of `ICertificateStore`.
-Program.cs could be as easy as this example.
+For multi-domain metadata support (serving signed metadata for multiple domains within a single community), see [Multi-Domain Metadata Support](../../docs/multi-domain-metadata.md).
-```csharp
+## Quick Start
+
+Program.cs can be as simple as:
-using Udap.Common;
+```csharp
using Udap.Metadata.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllers()
- .UseUdapMetaDataServer(builder.Configuration);
-
-builder.Services.AddSingleton();
+ .AddUdapMetadataServer(builder.Configuration);
+var app = builder.Build();
+app.UseUdapMetadataServer();
+app.MapControllers();
+app.Run();
```
-## Full Example
-
-Below is a full example. Alternatively the [2023 FHIR® DevDays Tutorial](udap-devdays-2023) is another great way to learn how to use ```Udap.Metadata.Server```.
-
-Add this package to your FHIR® server or any web api server to.
-
-```csharp
-
-dotnet new sln -o WebApiProject1
-cd WebApiProject1
-
-dotnet new webapi -o WebApi1 -minimal
-dotnet sln add ./WebApi1/WebApi1.csproj
-
-cd WebApi1
-
-```
+You can provide your own certificate store implementation:
```csharp
-dotnet add package Udap.Metadata.Server
+builder.Services.AddSingleton();
```
-Or until a first release use the --prerelease tag.
-
-```csharp
-
-dotnet add package Udap.Metadata.Server --prerelease
-
-dotnet build
+## Setup
+```bash
+dotnet add package Udap.Metadata.Server
```
-Add the UseUdapMetaDataServer service extension to program.cs
+Add the `AddUdapMetadataServer` service extension and `UseUdapMetadataServer` middleware to Program.cs:
```csharp
-
- builder.Services
+builder.Services
.AddControllers()
- .AddUdapMetaDataServer(builder.Configuration);
+ .AddUdapMetadataServer(builder.Configuration);
+// ...
+
+// Place before UseRouting() and UseAuthentication() so metadata
+// requests are handled anonymously
+app.UseUdapMetadataServer();
+app.UseRouting();
+app.UseAuthentication();
+app.UseAuthorization();
```
-By default, ```AddUdapMetaDataServer``` registers the default ```IUdapMetadataOptionsProvider``` implmentation of ```UdapMetadataOptionsProvider```. ```UdapMetadataOptionsProvider``` finds the UdapMetadataOptionsFile in AppSettings
+By default, `AddUdapMetadataServer` registers `UdapMetadataOptionsProvider` which reads the metadata options file path from AppSettings:
```json
"UdapMetadataOptionsFile": "udap.metadata.options.json"
@@ -72,41 +63,36 @@ By default, ```AddUdapMetaDataServer``` registers the default ```IUdapMetadataOp
udap.metadata.options.json:
```json
-
{
"UdapVersionsSupported": [ "1" ],
- "UdapProfilesSupported": [ "udap_dcr", "udap_authn", "udap_authz", "udap_to" ],
- "UdapAuthorizationExtensionsSupported": [ "hl7-b2b" ],
- "UdapAuthorizationExtensionsRequired": [ "hl7-b2b" ],
- "ScopesSupported": [ "openid", "system/*.read", "user/*.read", "patient/*.read" ],
- "UdapCertificationsSupported": [ "http://MyUdapCertification", "http://MyUdapCertification2" ],
- "UdapCertificationsRequired": [ "http://MyUdapCertification" ],
- "GrantTypesSupported": [ "authorization_code", "refresh_token", "client_credentials" ],
- //"TokenEndpointAuthSigningAlgValuesSupported": [ "RS256", "RS384", "ES256", "ES384" ],
- //"RegistrationEndpointJwtSigningAlgValuesSupported": [ "RS256", "RS384", "ES256", "ES384" ],
-
- "UdapMetadataConfigs": [
- {
- "Community": "http://localhost",
- "SignedMetadataConfig": {
- "AuthorizationEndpoint": "https://securedcontrols.net:5001/connect/authorize",
- "TokenEndpoint": "https://securedcontrols.net:5001/connect/token",
- "RegistrationEndpoint": "https://securedcontrols.net:5001/connect/register"
- }
+ "UdapProfilesSupported": [ "udap_dcr", "udap_authn", "udap_authz", "udap_to" ],
+ "ScopesSupported": [ "openid", "system/*.read", "user/*.read", "patient/*.read" ],
+ "UdapCertificationsSupported": [ "http://MyUdapCertification", "http://MyUdapCertification2" ],
+ "UdapCertificationsRequired": [ "http://MyUdapCertification" ],
+ "GrantTypesSupported": [ "authorization_code", "refresh_token", "client_credentials" ],
+
+ "UdapMetadataConfigs": [
+ {
+ "Community": "http://localhost",
+ "SignedMetadataConfig": {
+ "AuthorizationEndpoint": "https://securedcontrols.net:5001/connect/authorize",
+ "TokenEndpoint": "https://securedcontrols.net:5001/connect/token",
+ "RegistrationEndpoint": "https://securedcontrols.net:5001/connect/register"
}
- ]
+ }
+ ]
}
```
-## UDAP Metadata Options: see [Required UDAP Metadata](http://hl7.org/fhir/us/udap-security/discovery.html#signed-metadata-elements)
+## UDAP Metadata Options
-The `UdapMetadataOptions` class defines the configurable properties for UDAP metadata, as seen above in `udap.metadata.options.json`.
+See [Required UDAP Metadata](http://hl7.org/fhir/us/udap-security/discovery.html#signed-metadata-elements).
-### Known Properties
+The `UdapMetadataOptions` class defines the configurable properties:
- **UdapVersionsSupported**: Array of supported UDAP versions (e.g., `["1"]`)
- **UdapProfilesSupported**: Array of supported UDAP profiles (e.g., `["udap_dcr", "udap_authn"]`)
-- **UdapAuthorizationExtensionsSupported**: Array of supported authorization extensions
+- **UdapAuthorizationExtensionsSupported**: Array of supported authorization extensions (e.g., `["hl7-b2b", "tefca-ias"]`)
- **UdapAuthorizationExtensionsRequired**: Array of required authorization extensions
- **UdapCertificationsSupported**: Array of supported certifications
- **UdapCertificationsRequired**: Array of required certifications
@@ -119,39 +105,20 @@ The `UdapMetadataOptions` class defines the configurable properties for UDAP met
### Extending Metadata
-You can add additional custom properties to your `udap.metadata.options.json` file. Any extra properties not explicitly defined above will be loaded and made available in the published metadata via the `ExtensionData` dictionary. This allows for flexible extension of the metadata without modifying the core model.
-
+Any extra properties in your `udap.metadata.options.json` file not listed above will be loaded and made available in the published metadata via the `ExtensionData` dictionary.
## Certificate Store
-The settings in ```udap.metadata.options.json``` will match the IssuedCerts settings in UdapFileCertStoreManifest settings of the appsettings.json. See below.
-
-To serve UDAP metadata, certificates will be loaded through an implementation of ```ICertificatStore```. Below is the built-in file-based implementation for lab experiments.
+To serve UDAP metadata, certificates are loaded through an implementation of `ICertificateStore`. Below is the built-in file-based implementation:
```csharp
-// UDAP CertStore
builder.Services.Configure(builder.Configuration.GetSection("UdapFileCertStoreManifest"));
builder.Services.AddSingleton();
```
-To continue this example, copy the following files from the Udap.PKI.Generator test project output to the following directory structure at the root of the WebApi1 project. Ensure each file's "Copy to Output Directory" is set to copy.
-
-- CertStore
- - issued
- - weatherApiClientLocalhostCert.pfx
-
-Add configuration to AppSettings to point to the certificates.
-
-**Note From AppSettings**
-
-UdapMetadataOptions:UdapMetadataConfigs:Community value is the link to UdapFileCertStoreManifest:ResourceServers:Communities.Name. In this example the community is identified by the name `http://localhost`. Community names are [constrained as a URI](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities)
+Configure certificate paths in appsettings.json. The community name links `UdapMetadataConfigs` to `UdapFileCertStoreManifest`. Community names are [constrained as a URI](http://hl7.org/fhir/us/udap-security/discovery.html#multiple-trust-communities).
```json
-/*
- Normally put someplace safer like secrets.json or secured database
- and add this to Program.cs.
-*/
-
"UdapFileCertStoreManifest": {
"Communities": [
{
@@ -163,77 +130,17 @@ UdapMetadataOptions:UdapMetadataConfigs:Community value is the link to UdapFileC
}
]
}
- ]
+ ]
}
```
-```csharp
-dotnet run
-```
+## Examples
-Navigate to http://localhost:5079/.well-known/udap or http://localhost:5079/swagger.
-
-At this point a success would result in a result similar to the following json. Ensure the signed_metadata property contains a signed JWT token.
-
-View Metadata>
-
-```json
-{
- "udap_versions_supported": [
- "1"
- ],
- "udap_profiles_supported": [
- "udap_dcr",
- "udap_authn",
- "udap_authz"
- ],
- "udap_authorization_extensions_supported": [
- "hl7-b2b"
- ],
- "udap_authorization_extensions_required": [
- "hl7-b2b"
- ],
- "udap_certifications_supported": [
- "http://MyUdapCertification",
- "http://MyUdapCertification2"
- ],
- "udap_certifications_required": [
- "http://MyUdapCertification"
- ],
- "grant_types_supported": [
- "client_credentials"
- ],
- "scopes_supported": [
- "openid",
- "system/Patient.read",
- "system/AllergyIntolerance.read",
- "system/Procedures.read",
- "system/Observation.read"
- ],
- "authorization_endpoint": "https://securedcontrols.net/connect/authorize",
- "token_endpoint": "https://securedcontrols.net/connect/token",
- "token_endpoint_auth_methods_supported": [
- "private_key_jwt"
- ],
- "token_endpoint_auth_signing_alg_values_supported": [
- "RS256"
- ],
- "registration_endpoint": "https://securedcontrols.net/connect/register",
- "registration_endpoint_jwt_signing_alg_values_supported": [
- "RS256"
- ],
- "signed_metadata": "eyJhbGciOiJSUzI1NiIsIng1YyI6WyJNSUlGR3pDQ0JBT2dBd0lCQWdJSUZSVVJqcWdlTkdNd0RRWUpLb1pJaHZjTkFRRUxCUUF3Z2JNeEN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUlEQXBEWVd4cFptOXlibWxoTVJJd0VBWURWUVFIREFsVFlXNGdSR2xsWjI4eEV6QVJCZ05WQkFvTUNrVk5VaUJFYVhKbFkzUXhQekE5QmdOVkJBc01ObFJsYzNRZ1VFdEpJRU5sY25ScFptbGpZWFJwYjI0Z1FYVjBhRzl5YVhSNUlDaGpaWEowY3k1bGJYSmthWEpsWTNRdVkyOXRLVEVsTUNNR0ExVUVBd3djUlUxU0lFUnBjbVZqZENCVVpYTjBJRU5zYVdWdWRDQlRkV0pEUVRBZUZ3MHlNakE1TVRVeU1ETXpOVEphRncweU16QTVNVFV5TURNek5USmFNSUdwTVFzd0NRWURWUVFHRXdKVlV6RVBNQTBHQTFVRUNBd0dUM0psWjI5dU1TZ3dKZ1lEVlFRS0RCOVRkWEpsYzJOeWFYQjBjeUJNVEVNZ0tITmxiR1lnWVhOelpYSjBaV1FwTVRNd01RWURWUVFMRENwVlJFRlFJRlJsYzNRZ1EyVnlkR2xtYVdOaGRHVWdUazlVSUVaUFVpQlZVMFVnVjBsVVNDQlFTRWt4S2pBb0JnTlZCQU1NSVdoMGRIQnpPaTh2Wm1ocGNteGhZbk11Ym1WME9qY3dNVFl2Wm1ocGNpOXlORENDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFJQkgrSUtIRUJ4SDIyN09BYkRsTGYxS0k4b1UxZE8vZmp2ZzFQbkJNSlQ0RjQrL1BFWmlOdkRhS0dFT09lOXVvTmVMdGlEWEt0aFVQSEdEMm54RXVSL2lQeXluVmFETmtHYkZvc2d3c01JMXU4bGFJbHNwQWVrR2d5VWlPZzB3a1NRbEF4TjJuaFVqR3dMbjllUzBPWld0eGhUcHBNNEFGbElwY1hackFLeTlOZm53S2NGeUtvUmg3Zlo4bDlSR1hHeFl6ZXh2ejJ0LzhCbG5xb3ZQODZlWktHaFBxTTlFTGZPNTc4R1UrNWJCcFNqWUdsenhwemVnanZaUkR5bnBVbEJBdEtvWDBOdXh6ZjJ6SURvOVZwaldoVG9TKzZ0eDZJRFVNZVdEZHZjQytPQnNTNjNUdisxN2VFSVdpRjlGb0xNYUNUZXJRMFluaWlwVGQ3NDdGT2NDQXdFQUFhT0NBVGt3Z2dFMU1Ga0dDQ3NHQVFVRkJ3RUJCRTB3U3pCSkJnZ3JCZ0VGQlFjd0FvWTlhSFIwY0RvdkwyTmxjblJ6TG1WdGNtUnBjbVZqZEM1amIyMHZZMlZ5ZEhNdlJVMVNSR2x5WldOMFZHVnpkRU5zYVdWdWRGTjFZa05CTG1OeWREQWRCZ05WSFE0RUZnUVVuMDUzdk9jYVdINzRsR1c4VVlYazk4WU5nOUV3REFZRFZSMFRBUUgvQkFJd0FEQWZCZ05WSFNNRUdEQVdnQlNqbFcxcnZTdFJ6ZUhQNVpCdjF5WlB2OTArM2pCTUJnTlZIUjhFUlRCRE1FR2dQNkE5aGp0b2RIUndPaTh2WTJWeWRITXVaVzF5WkdseVpXTjBMbU52YlM5amNtd3ZSVTFTUkdseVpXTjBWR1Z6ZEVOc2FXVnVkRk4xWWtOQkxtTnliREFPQmdOVkhROEJBZjhFQkFNQ0I0QXdMQVlEVlIwUkJDVXdJNFloYUhSMGNITTZMeTltYUdseWJHRmljeTV1WlhRNk56QXhOaTltYUdseUwzSTBNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUI1VkswWkhWZXpMdUYvY2FieW1ZOWFLa0pENXhxY0JWVFNjeGVYQ3NMaWloLzhFS0NwdmVVSWl6NDJ5U3JtbHBJS2ljby95c1ByWHZKbU8vVnJHMjFWbnpZNkZKQjE3empXbkQ2bncvRnRFNXU0V2laTTE2aGcxUzJpa01FYXMzRjU3L3FrYjNLMzdXUm1IVDdickphUUtGZFYzWWRrVFloZ1cvbjFTellqWnEwZ0w0bDZWcVBSeCsxSWpaUkQxNWowZVFOV1hrR1lvWmlsR3duSFFJOUhKSGxadmMxZ1VLeFl2dDhwR2hlL0ZwZmF0cW9QVlhVY09CRVlBTHNrNmdlUDBhR0Z1M0xQa3NxdjZpZTM2M01tZWp5WEtxeE1uUThHcUR1bVNBU1ZhbDhyVmw4ZjE1NzlwUDc4aGxDYWNzam4zdTBnNVJLRDVPUk4rQTlJTTRDMyJdfQ.eyJpc3MiOiJodHRwczovL3N0YWdlLmhlYWx0aHRvZ28ubWU6ODE4MSIsInN1YiI6Imh0dHBzOi8vc3RhZ2UuaGVhbHRodG9nby5tZTo4MTgxIiwiaWF0IjoxNjc2OTM3NjI3LCJleHAiOjE2NzY5Mzc2ODcsImp0aSI6Ik95N0RaenVhXzBYbDhEaFNRXzVONzFxeHFBcllLdEI3OUdmRkVGQVFaUkUiLCJhdXRob3JpemF0aW9uX2VuZHBvaW50IjoiaHR0cHM6Ly9zZWN1cmVkY29udHJvbHMubmV0L2Nvbm5lY3QvYXV0aG9yaXplIiwidG9rZW5fZW5kcG9pbnQiOiJodHRwczovL3NlY3VyZWRjb250cm9scy5uZXQvY29ubmVjdC90b2tlbiIsInJlZ2lzdHJhdGlvbl9lbmRwb2ludCI6Imh0dHBzOi8vc2VjdXJlZGNvbnRyb2xzLm5ldC9jb25uZWN0L3JlZ2lzdGVyIn0.Y9qWVQFs9HXWipN8YDrH7gf89FoA0V7f3p9vqc6bPuqrcI0B6wgqZ2ZC3FYi46nGvpe6G_H20edXYR7zIHqcXqhtjfYNmCYoH-ceVwvq6kCAm0c4v8BXN23SM1Eh72_481Bbf7PidHUzcAIOn7fJ9DAk-LiVsT9aa7TD2Aj11cLC5ZiuoHyLCOaf6sjK-yX707ov313TEQREgLbSnl-YTwbIgmm_h3fW4eSZH2eszdr3a3Q8BWKKVBphWos5TvQ77WsYfTt60JfFHEXO8Psq7n4bGm2ZcNApzoa9PIuimmzeN8vjyaLBu7lDi93cc9jKphYz3KpLh_-8ruHF2HqmNw"
-}
-```
-
-
-
-
-### UDAP Resource Server Examples
-
-- [FhirLabsApi example project](./examples//FhirLabsApi/)
+- [FhirLabsApi example project](../../examples/FhirLabsApi/)
- [FhirLabs Published](https://fhirlabs.net/fhir/r4/.well-known/udap)
-- [FhirLabs UdapEd Tool | Discovery | Registration | B2B | Patient Match | National Directory ](https://udaped.fhirlabs.net)
+- [UdapEd Tool](https://udaped.fhirlabs.net)
+
+---
- FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7.
-- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. UDAP Draft Specifications are referenced and displayed in parts of this source code to document specification implementation.
+- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org.
diff --git a/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs b/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs
index 7a564872b..4cd9dfd86 100644
--- a/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs
+++ b/Udap.Model/Access/AccessTokenRequestForAuthorizationCodeBuilder.cs
@@ -1,8 +1,8 @@
-#region (c) 2023 Joseph Shook. All rights reserved.
+#region (c) 2023 Joseph Shook. All rights reserved.
// /*
// Authors:
// Joseph Shook Joseph.Shook@Surescripts.com
-//
+//
// See LICENSE in the project root for license information.
// */
#endregion
@@ -19,7 +19,7 @@
namespace Udap.Model.Access;
///
-///
+///
///
public class AccessTokenRequestForAuthorizationCodeBuilder
{
@@ -30,18 +30,18 @@ public class AccessTokenRequestForAuthorizationCodeBuilder
private readonly string? _code;
private readonly string? _redirectUri;
private readonly DateTime _now;
- private readonly X509Certificate2 _certificate;
+ private readonly List _certificates;
- private AccessTokenRequestForAuthorizationCodeBuilder(string? clientId, string? tokenEndpoint, X509Certificate2 certificate, string? redirectUri, string? code)
+ private AccessTokenRequestForAuthorizationCodeBuilder(string? clientId, string? tokenEndpoint, List certificates, string? redirectUri, string? code)
{
_now = DateTime.UtcNow.ToUniversalTime();
_tokenEndpoint = tokenEndpoint;
_clientId = clientId;
- _certificate = certificate;
+ _certificates = certificates;
_code = code;
_redirectUri = redirectUri;
-
+
_claims =
[
new Claim(JwtClaimTypes.IssuedAt, EpochTime.GetIntDate(_now).ToString(), ClaimValueTypes.Integer),
@@ -57,7 +57,21 @@ private AccessTokenRequestForAuthorizationCodeBuilder(string? clientId, string?
public static AccessTokenRequestForAuthorizationCodeBuilder Create(string? clientId, string? tokenEndpoint, X509Certificate2 certificate, string? redirectUri, string? code)
{
- return new AccessTokenRequestForAuthorizationCodeBuilder(clientId, tokenEndpoint, certificate, redirectUri, code);
+ return new AccessTokenRequestForAuthorizationCodeBuilder(clientId, tokenEndpoint, new List { certificate }, redirectUri, code);
+ }
+
+ ///
+ /// Create a builder with an ordered certificate chain where the first certificate is the end entity certificate.
+ ///
+ ///
+ ///
+ /// Certificate chain where the first certificate is the end entity certificate
+ ///
+ ///
+ ///
+ public static AccessTokenRequestForAuthorizationCodeBuilder Create(string? clientId, string? tokenEndpoint, List certificates, string? redirectUri, string? code)
+ {
+ return new AccessTokenRequestForAuthorizationCodeBuilder(clientId, tokenEndpoint, certificates, redirectUri, code);
}
///
@@ -107,7 +121,7 @@ private string BuildClientAssertion(string? algorithm)
);
return SignedSoftwareStatementBuilder
- .Create(_certificate, jwtPayload)
+ .Create(_certificates, jwtPayload)
.Build(algorithm);
}
-}
\ No newline at end of file
+}
diff --git a/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs b/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs
index 80c7c9c91..f93afb022 100644
--- a/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs
+++ b/Udap.Model/Access/AccessTokenRequestForClientCredentialsBuilder.cs
@@ -1,8 +1,8 @@
-#region (c) 2023 Joseph Shook. All rights reserved.
+#region (c) 2023 Joseph Shook. All rights reserved.
// /*
// Authors:
// Joseph Shook Joseph.Shook@Surescripts.com
-//
+//
// See LICENSE in the project root for license information.
// */
#endregion
@@ -20,25 +20,25 @@
namespace Udap.Model.Access;
///
-///
+///
///
public class AccessTokenRequestForClientCredentialsBuilder
{
-
+
private readonly List _claims;
private readonly string? _tokenEndoint;
private readonly string? _clientId;
private readonly DateTime _now;
- private readonly X509Certificate2 _certificate;
+ private readonly List _certificates;
private string? _scope;
private readonly Dictionary _extensions = [];
- private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string? tokenEndpoint, X509Certificate2 certificate)
+ private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string? tokenEndpoint, List certificates)
{
_now = DateTime.UtcNow.ToUniversalTime();
_tokenEndoint = tokenEndpoint;
_clientId = clientId;
- _certificate = certificate;
+ _certificates = certificates;
_claims =
[
@@ -46,7 +46,7 @@ private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string?
new Claim(JwtClaimTypes.JwtId, CryptoRandom.CreateUniqueId())
// new Claim(UdapConstants.JwtClaimTypes.Extensions, BuildHl7B2BExtensions() ) //see http://hl7.org/fhir/us/udap-security/b2b.html#constructing-authentication-token
];
-
+
if (_clientId != null)
{
_claims.Add(new Claim(JwtClaimTypes.Subject, _clientId));
@@ -55,9 +55,21 @@ private AccessTokenRequestForClientCredentialsBuilder(string? clientId, string?
public static AccessTokenRequestForClientCredentialsBuilder Create(string? clientId, string? tokenEndpoint, X509Certificate2 certificate)
{
- return new AccessTokenRequestForClientCredentialsBuilder(clientId, tokenEndpoint, certificate);
+ return new AccessTokenRequestForClientCredentialsBuilder(clientId, tokenEndpoint, new List { certificate });
+ }
+
+ ///
+ /// Create a builder with an ordered certificate chain where the first certificate is the end entity certificate.
+ ///
+ ///
+ ///
+ /// Certificate chain where the first certificate is the end entity certificate
+ ///
+ public static AccessTokenRequestForClientCredentialsBuilder Create(string? clientId, string? tokenEndpoint, List certificates)
+ {
+ return new AccessTokenRequestForClientCredentialsBuilder(clientId, tokenEndpoint, certificates);
}
-
+
///
/// Add more claims
///
@@ -74,7 +86,7 @@ public AccessTokenRequestForClientCredentialsBuilder WithScope(string scope)
_scope = scope;
return this;
}
-
+
public AccessTokenRequestForClientCredentialsBuilder WithExtension(string key, T value) where T : class
{
var jsonElement = JsonSerializer.Deserialize(JsonSerializer.Serialize(value));
@@ -104,7 +116,7 @@ public UdapClientCredentialsTokenRequest Build(string? algorithm = UdapConstants
Scope = _scope,
};
}
-
+
private string BuildClientAssertion(string? algorithm)
{
@@ -123,9 +135,9 @@ private string BuildClientAssertion(string? algorithm)
var payload = jwtPayload as Dictionary;
payload.Add(UdapConstants.JwtClaimTypes.Extensions, _extensions);
}
-
+
return SignedSoftwareStatementBuilder
- .Create(_certificate, jwtPayload)
+ .Create(_certificates, jwtPayload)
.Build(algorithm);
}
-}
\ No newline at end of file
+}
diff --git a/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs b/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs
index d51d99249..3c52fb16c 100644
--- a/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs
+++ b/Udap.Model/Registration/UdapCertificationAndEndorsementDocument.cs
@@ -254,6 +254,14 @@ public UdapCertificationAndEndorsementDocument(string certificationName)
[JsonPropertyName(UdapConstants.CertificationAndEndorsementDocumentValues.Jwks)]
public string? Jwks { get; set; }
+ ///
+ /// Additional claims not modeled as explicit properties.
+ /// Any unknown JSON properties will be captured here during deserialization
+ /// and included during serialization.
+ ///
+ [JsonExtensionData]
+ public Dictionary? AdditionalClaims { get; set; }
+
///
/// Serializes this instance to JSON.
///
diff --git a/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs b/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs
index d5db9f525..a2c77336b 100644
--- a/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs
+++ b/Udap.Model/Registration/UdapCertificationsAndEndorsementBuilder.cs
@@ -9,6 +9,7 @@
using System.Security.Cryptography.X509Certificates;
using System;
+using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using Duende.IdentityModel;
using System.Collections.Generic;
@@ -639,6 +640,17 @@ public UdapCertificationsAndEndorsementBuilder WithCertificate(X509Certificate2
return this;
}
+
+ ///
+ /// Set additional claims that are not modeled as explicit properties on
+ /// .
+ /// These claims will be included as top-level properties in the signed JWT payload.
+ ///
+ public UdapCertificationsAndEndorsementBuilder WithAdditionalClaims(Dictionary? additionalClaims)
+ {
+ _document.AdditionalClaims = additionalClaims;
+ return this;
+ }
public UdapCertificationAndEndorsementDocument Build()
{
diff --git a/Udap.Model/UdapMetadataConfig.cs b/Udap.Model/UdapMetadataConfig.cs
index d41c68fa2..c3c4ab1d4 100644
--- a/Udap.Model/UdapMetadataConfig.cs
+++ b/Udap.Model/UdapMetadataConfig.cs
@@ -7,6 +7,8 @@
// */
#endregion
+using System.Collections.Generic;
+
namespace Udap.Model;
public class UdapMetadataConfig
@@ -17,6 +19,17 @@ public class UdapMetadataConfig
///
public string Community { get; set; } = string.Empty;
+ ///
+ /// Per-community override for udap_certifications_supported.
+ /// When set, replaces the root-level .
+ ///
+ public HashSet? UdapCertificationsSupported { get; set; }
+
+ ///
+ /// Per-community override for udap_certifications_required.
+ /// When set, replaces the root-level .
+ ///
+ public HashSet? UdapCertificationsRequired { get; set; }
///
/// See Signed metadata elements
diff --git a/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs b/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs
index adef17198..33bdd4627 100644
--- a/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs
+++ b/Udap.Server.Storage/Stores/IUdapClientRegistrationStore.cs
@@ -51,5 +51,10 @@ public interface IUdapClientRegistrationStore
Task GetAnchorsCertificates(string? community, CancellationToken token = default);
Task GetCommunityId(string community, CancellationToken token = default);
+ ///
+ /// Reverse lookup: resolves a community name (URI) from a community ID.
+ ///
+ Task GetCommunityName(string communityId, CancellationToken token = default);
+
Task?> RolloverClientSecrets(ParsedSecret secret, CancellationToken token = default);
}
\ No newline at end of file
diff --git a/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs b/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs
index f5ebed332..fe6121812 100644
--- a/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs
+++ b/Udap.Server.Storage/Stores/UdapClientRegistrationStore.cs
@@ -287,6 +287,21 @@ public async Task> GetAnchors(string? community, Cancellatio
.SingleOrDefaultAsync(token);
}
+ public async Task GetCommunityName(string communityId, CancellationToken token = default)
+ {
+ using var activity = Tracing.StoreActivitySource.StartActivity();
+
+ if (!int.TryParse(communityId, out var id))
+ {
+ return null;
+ }
+
+ return await _dbContext.Communities
+ .Where(c => c.Id == id)
+ .Select(c => c.Name)
+ .SingleOrDefaultAsync(token);
+ }
+
public async Task?> RolloverClientSecrets(ParsedSecret secret, CancellationToken token = default)
{
var rolled = false;
diff --git a/Udap.Server.Storage/UdapServerConstants.cs b/Udap.Server.Storage/UdapServerConstants.cs
index a6c83b748..b1f08d5d5 100644
--- a/Udap.Server.Storage/UdapServerConstants.cs
+++ b/Udap.Server.Storage/UdapServerConstants.cs
@@ -27,5 +27,6 @@ public static class ClientPropertyConstants
public static class HttpContextItems
{
public const string UdapErrorDescription = "udap:error_description";
+ public const string UdapErrorExtensions = "udap:error_extensions";
}
}
diff --git a/Udap.Server/Configuration/CommunityServerSettings.cs b/Udap.Server/Configuration/CommunityServerSettings.cs
deleted file mode 100644
index 8ea4a07b8..000000000
--- a/Udap.Server/Configuration/CommunityServerSettings.cs
+++ /dev/null
@@ -1,80 +0,0 @@
-#region (c) 2026 Joseph Shook. All rights reserved.
-// /*
-// Authors:
-// Joseph Shook Joseph.Shook@Surescripts.com
-//
-// See LICENSE in the project root for license information.
-// */
-#endregion
-
-using System.Text.Json.Serialization;
-
-namespace Udap.Server.Configuration;
-
-///
-/// Per-community overrides for .
-/// When a client belongs to a community that has a matching entry here,
-/// these settings take precedence over the global defaults.
-///
-public class CommunityServerSettings
-{
- ///
- /// The community name (e.g., "udap://fhirlabs.net") used to match
- /// against the client's registered community.
- ///
- [JsonPropertyName("Community")]
- public string Community { get; set; } = string.Empty;
-
- ///
- /// Authorization extension key names required by this community in every
- /// token request regardless of grant type (e.g., ["hl7-b2b"]).
- /// When null, falls back to the global .
- /// When grant-type-specific settings (
- /// or ) are set, they take precedence.
- ///
- [JsonPropertyName("AuthorizationExtensionsRequired")]
- public HashSet? AuthorizationExtensionsRequired { get; set; }
-
- ///
- /// Authorization extension key names required for client_credentials token requests
- /// in this community (e.g., ["hl7-b2b"]). When set, takes precedence over
- /// for client_credentials grants.
- /// When null, falls back to ,
- /// then to the global .
- ///
- [JsonPropertyName("ClientCredentialsExtensionsRequired")]
- public HashSet? ClientCredentialsExtensionsRequired { get; set; }
-
- ///
- /// Authorization extension key names required for authorization_code token requests
- /// in this community (e.g., ["hl7-b2b-user"]). When set, takes precedence over
- /// for authorization_code grants.
- /// When null, falls back to ,
- /// then to the global .
- ///
- [JsonPropertyName("AuthorizationCodeExtensionsRequired")]
- public HashSet? AuthorizationCodeExtensionsRequired { get; set; }
-
- ///
- /// Allowed purpose_of_use codes for this community. When set, every code
- /// in the extension's purpose_of_use array must appear in this set.
- /// When null, falls back to the global .
- /// An empty set means no purpose_of_use codes are accepted.
- ///
- /// SSRAA communities typically use HL7 v3 PurposeOfUse codes
- /// (e.g., "urn:oid:2.16.840.1.113883.5.8#TREAT").
- /// TEFCA communities use XP codes
- /// (e.g., "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT").
- ///
- ///
- [JsonPropertyName("AllowedPurposeOfUse")]
- public HashSet? AllowedPurposeOfUse { get; set; }
-
- ///
- /// Maximum number of purpose_of_use entries allowed in the extension.
- /// When null, falls back to the global .
- /// TEFCA requires exactly 1; base SSRAA allows multiple.
- ///
- [JsonPropertyName("MaxPurposeOfUseCount")]
- public int? MaxPurposeOfUseCount { get; set; }
-}
diff --git a/Udap.Server/Configuration/ServerSettings.cs b/Udap.Server/Configuration/ServerSettings.cs
index 1d3a35f87..37af59944 100644
--- a/Udap.Server/Configuration/ServerSettings.cs
+++ b/Udap.Server/Configuration/ServerSettings.cs
@@ -92,56 +92,7 @@ public class ServerSettings
///
public bool ForceDPoP { get; set; }
- ///
- /// Authorization extension key names required by this server in every token request
- /// regardless of grant type (e.g., ["hl7-b2b"]). This is the global default;
- /// per-community overrides can be specified in .
- /// When grant-type-specific settings (
- /// or ) are set, they take precedence.
- /// Validated by .
- ///
- [JsonPropertyName("AuthorizationExtensionsRequired")]
- public HashSet? AuthorizationExtensionsRequired { get; set; }
-
- ///
- /// Authorization extension key names required for client_credentials token requests
- /// (e.g., ["hl7-b2b"]). When set, takes precedence over
- /// for client_credentials grants. Per-community overrides in .
- ///
- [JsonPropertyName("ClientCredentialsExtensionsRequired")]
- public HashSet? ClientCredentialsExtensionsRequired { get; set; }
-
- ///
- /// Authorization extension key names required for authorization_code token requests
- /// (e.g., ["hl7-b2b-user"]). When set, takes precedence over
- /// for authorization_code grants. Per-community overrides in .
- ///
- [JsonPropertyName("AuthorizationCodeExtensionsRequired")]
- public HashSet? AuthorizationCodeExtensionsRequired { get; set; }
- ///
- /// Allowed purpose_of_use codes (global default). When set, every code
- /// in the extension's purpose_of_use array must appear in this set.
- /// Null means no restriction. Per-community overrides in .
- ///
- [JsonPropertyName("AllowedPurposeOfUse")]
- public HashSet? AllowedPurposeOfUse { get; set; }
-
- ///
- /// Maximum number of purpose_of_use entries allowed (global default).
- /// Null means no limit. TEFCA communities would set this to 1.
- /// Per-community overrides in .
- ///
- [JsonPropertyName("MaxPurposeOfUseCount")]
- public int? MaxPurposeOfUseCount { get; set; }
-
- ///
- /// Per-community overrides for server settings. When a client belongs to a community
- /// that has a matching entry here, the community-specific settings take precedence
- /// over the global defaults.
- ///
- [JsonPropertyName("CommunitySettings")]
- public List? CommunitySettings { get; set; }
}
diff --git a/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs b/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs
index f6dc976c5..a066e1555 100644
--- a/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs
+++ b/Udap.Server/Hosting/UdapTokenResponseMiddleware.cs
@@ -60,39 +60,67 @@ public async Task Invoke(HttpContext context)
}
if (context.Response.StatusCode is 400 or 401
- && context.Items.TryGetValue(UdapServerConstants.HttpContextItems.UdapErrorDescription, out var errorDescObj)
- && errorDescObj is string errorDescription
&& !string.IsNullOrEmpty(responseBody))
{
- try
- {
- using var doc = JsonDocument.Parse(responseBody);
- var root = doc.RootElement;
+ var hasErrorDescription =
+ context.Items.TryGetValue(UdapServerConstants.HttpContextItems.UdapErrorDescription, out var errorDescObj)
+ && errorDescObj is string;
+
+ var hasErrorExtensions =
+ context.Items.TryGetValue(UdapServerConstants.HttpContextItems.UdapErrorExtensions, out var errorExtObj)
+ && errorExtObj is Dictionary;
- if (root.ValueKind == JsonValueKind.Object && !root.TryGetProperty("error_description", out _))
+ if (hasErrorDescription || hasErrorExtensions)
+ {
+ try
{
- using var ms = new MemoryStream();
- using (var writer = new Utf8JsonWriter(ms))
+ using var doc = JsonDocument.Parse(responseBody);
+ var root = doc.RootElement;
+
+ if (root.ValueKind == JsonValueKind.Object)
{
- writer.WriteStartObject();
+ var needsErrorDescription = hasErrorDescription
+ && !root.TryGetProperty("error_description", out _);
+ var needsExtensions = hasErrorExtensions
+ && !root.TryGetProperty("extensions", out _);
- foreach (var property in root.EnumerateObject())
+ if (needsErrorDescription || needsExtensions)
{
- property.WriteTo(writer);
+ using var ms = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(ms))
+ {
+ writer.WriteStartObject();
+
+ foreach (var property in root.EnumerateObject())
+ {
+ property.WriteTo(writer);
+ }
+
+ if (needsErrorDescription)
+ {
+ writer.WriteString("error_description", (string)errorDescObj!);
+ _logger.LogDebug("Injected error_description into token error response");
+ }
+
+ if (needsExtensions)
+ {
+ var extensions = (Dictionary)errorExtObj!;
+ writer.WritePropertyName("extensions");
+ JsonSerializer.Serialize(writer, extensions);
+ _logger.LogDebug("Injected extensions into token error response");
+ }
+
+ writer.WriteEndObject();
+ }
+
+ responseBody = System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
-
- writer.WriteString("error_description", errorDescription);
- writer.WriteEndObject();
}
-
- responseBody = System.Text.Encoding.UTF8.GetString(ms.ToArray());
-
- _logger.LogDebug("Injected error_description into token error response");
}
- }
- catch (JsonException ex)
- {
- _logger.LogDebug(ex, "Could not parse token response body for error_description injection");
+ catch (JsonException ex)
+ {
+ _logger.LogDebug(ex, "Could not parse token response body for error injection");
+ }
}
}
diff --git a/Udap.Server/Registration/ICommunityRegistrationValidator.cs b/Udap.Server/Registration/ICommunityRegistrationValidator.cs
new file mode 100644
index 000000000..8ebead4ff
--- /dev/null
+++ b/Udap.Server/Registration/ICommunityRegistrationValidator.cs
@@ -0,0 +1,40 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+namespace Udap.Server.Registration;
+
+///
+/// Validates a UDAP registration request for a specific community.
+/// Implementations are registered as a DI collection
+/// (following the same pattern as IAuthorizationExtensionDeserializer )
+/// and invoked for each registration whose resolved community matches.
+///
+/// Called after core UDAP validation succeeds (JWT, chain, scopes)
+/// but before the processor persists the client.
+///
+public interface ICommunityRegistrationValidator
+{
+ ///
+ /// Returns true if this validator should run for the given community.
+ ///
+ ///
+ /// The community URI string (e.g., urn:oid:2.16.840.1.113883.3.7204.1.5 ).
+ ///
+ bool AppliesToCommunity(string communityName);
+
+ ///
+ /// Validates community-specific rules on the registration context.
+ ///
+ ///
+ /// null if validation passes; a populated
+ /// with an error if rejected.
+ ///
+ Task ValidateAsync(
+ UdapDynamicClientRegistrationContext context);
+}
diff --git a/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs b/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs
index a1eda32bf..9399c5fba 100644
--- a/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs
+++ b/Udap.Server/Registration/UdapDynamicClientRegistrationContext.cs
@@ -60,6 +60,11 @@ public class UdapDynamicClientRegistrationContext
///
public long? CommunityId { get; set; }
+ ///
+ /// The community name (URI) resolved during chain validation.
+ ///
+ public string? CommunityName { get; set; }
+
///
/// Certificate expiration from the validated chain.
///
diff --git a/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs b/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs
index 741c28e36..a01a97c2d 100644
--- a/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs
+++ b/Udap.Server/Registration/UdapDynamicClientRegistrationEndpoint.cs
@@ -27,17 +27,20 @@ public class UdapDynamicClientRegistrationEndpoint
private readonly IUdapDynamicClientRegistrationValidator _validator;
private readonly IUdapDynamicClientRegistrationProcessor _processor;
private readonly IUdapClientRegistrationStore _store;
+ private readonly IEnumerable _communityRegistrationValidators;
private readonly ILogger _logger;
public UdapDynamicClientRegistrationEndpoint(
IUdapDynamicClientRegistrationValidator validator,
IUdapDynamicClientRegistrationProcessor processor,
IUdapClientRegistrationStore store,
+ IEnumerable communityRegistrationValidators,
ILogger logger)
{
_validator = validator;
_processor = processor;
_store = store;
+ _communityRegistrationValidators = communityRegistrationValidators;
_logger = logger;
}
@@ -102,7 +105,6 @@ await httpContext.Response.WriteAsJsonAsync(new UdapDynamicClientRegistrationErr
try
{
- // Not in pattern with other validators in IdentityServer. Typically, all errors handled in ValidateAsync... TODO
if (communityTrustAnchors == null)
{
throw new NullReferenceException("Missing Community Trust Anchors");
@@ -142,6 +144,32 @@ await httpContext.Response.WriteAsJsonAsync(new UdapDynamicClientRegistrationErr
return;
}
+ // Community-specific registration validation
+ if (!string.IsNullOrEmpty(context.CommunityName))
+ {
+ foreach (var communityValidator in _communityRegistrationValidators)
+ {
+ if (communityValidator.AppliesToCommunity(context.CommunityName))
+ {
+ var communityResult = await communityValidator.ValidateAsync(context);
+ if (communityResult is { IsError: true })
+ {
+ httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
+
+ var communityError = new UdapDynamicClientRegistrationErrorResponse(
+ communityResult.Error ?? string.Empty,
+ communityResult.ErrorDescription ?? string.Empty);
+
+ _logger.LogWarning("Error: {@Error}", communityError);
+
+ await httpContext.Response.WriteAsJsonAsync(communityError, cancellationToken: token);
+
+ return;
+ }
+ }
+ }
+ }
+
// Process (create client + persist)
try
{
diff --git a/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs b/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs
index d0d85dfa7..90c1e4212 100644
--- a/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs
+++ b/Udap.Server/Registration/UdapDynamicClientRegistrationValidator.cs
@@ -710,6 +710,7 @@ private async Task ValidateChainAsync(
context.Issuer = jwtSecurityToken.Issuer;
context.CommunityId = result.CommunityId;
+ context.CommunityName = result.CommunityName;
context.CertificateExpiration = result.ChainElements.First().Certificate.NotAfter.ToUniversalTime();
return true;
diff --git a/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs b/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs
index fc99ef6eb..3440d6afb 100644
--- a/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs
+++ b/Udap.Server/Stores/InMemory/InMemoryUdapClientRegistrationStore.cs
@@ -289,6 +289,23 @@ public Task> GetAnchors(string? community, CancellationToken
return Task.FromResult(id);
}
+ public Task GetCommunityName(string communityId, CancellationToken token = default)
+ {
+ if (!int.TryParse(communityId, out var id))
+ {
+ return Task.FromResult(null);
+ }
+
+ // Use FirstOrDefault because in-memory communities may share default IDs (0)
+ // when tests don't assign explicit IDs. The EF store uses SingleOrDefaultAsync
+ // since the database enforces unique primary keys.
+ var name = _communities.Where(c => c.Id == id)
+ .Select(c => c.Name)
+ .FirstOrDefault();
+
+ return Task.FromResult(name);
+ }
+
public Task?> RolloverClientSecrets(ParsedSecret secret, CancellationToken token = default)
{
var rolled = false;
diff --git a/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs b/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs
index bff05e510..3fd211af0 100644
--- a/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs
+++ b/Udap.Server/Validation/AuthorizationExtensionValidationResult.cs
@@ -7,6 +7,8 @@
// */
#endregion
+using System.Collections.Generic;
+
namespace Udap.Server.Validation;
///
@@ -18,9 +20,21 @@ public class AuthorizationExtensionValidationResult
public string? Error { get; set; }
public string? ErrorDescription { get; set; }
+ ///
+ /// Optional error extension data to include in the error response "extensions" object.
+ /// Keys are extension names (e.g., "hl7-b2b"), values are serializable objects.
+ /// Used by trust community profiles (e.g., TEFCA) to return additional error metadata
+ /// such as required consent policies.
+ ///
+ public Dictionary? ErrorExtensions { get; set; }
+
public static AuthorizationExtensionValidationResult Success()
=> new() { IsValid = true };
public static AuthorizationExtensionValidationResult Failure(string error, string description)
=> new() { IsValid = false, Error = error, ErrorDescription = description };
+
+ public static AuthorizationExtensionValidationResult Failure(
+ string error, string description, Dictionary errorExtensions)
+ => new() { IsValid = false, Error = error, ErrorDescription = description, ErrorExtensions = errorExtensions };
}
diff --git a/Udap.Server/Validation/CommunityValidationRules.cs b/Udap.Server/Validation/CommunityValidationRules.cs
new file mode 100644
index 000000000..e4b2cbce6
--- /dev/null
+++ b/Udap.Server/Validation/CommunityValidationRules.cs
@@ -0,0 +1,35 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+namespace Udap.Server.Validation;
+
+///
+/// Validation rules declared by a community token validator for a specific grant type.
+/// When a returns non-null rules from
+/// , these override
+/// global ServerSettings for that community.
+///
+public class CommunityValidationRules
+{
+ ///
+ /// Extension keys that must be present in the token request (e.g., "hl7-b2b").
+ /// Null means no requirement; empty set means explicitly no extensions required.
+ ///
+ public HashSet? RequiredExtensions { get; init; }
+
+ ///
+ /// Allowed purpose_of_use codes. Null means no restriction.
+ ///
+ public HashSet? AllowedPurposeOfUse { get; init; }
+
+ ///
+ /// Maximum number of purpose_of_use entries allowed. Null means no limit.
+ ///
+ public int? MaxPurposeOfUseCount { get; init; }
+}
diff --git a/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs b/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs
index 02cdd18e3..67eec7d40 100644
--- a/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs
+++ b/Udap.Server/Validation/Default/DefaultUdapAuthorizationExtensionValidator.cs
@@ -8,166 +8,169 @@
#endregion
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using Udap.Model;
using Udap.Model.UdapAuthenticationExtensions;
-using Udap.Server.Configuration;
using Udap.Server.Storage.Stores;
namespace Udap.Server.Validation.Default;
///
/// Default implementation of that
-/// enforces required authorization extensions based on
-/// with optional per-community overrides via .
+/// enforces required authorization extensions via
+/// implementations.
+///
+/// Community validators provide rules via
+/// (required extensions, allowed POU codes, max count) and domain-specific validation
+/// via .
+///
+/// When no community validator applies, no extension or POU enforcement occurs.
///
public class DefaultUdapAuthorizationExtensionValidator : IUdapAuthorizationExtensionValidator
{
- private readonly IOptionsMonitor _serverSettings;
private readonly IUdapClientRegistrationStore _clientStore;
+ private readonly IEnumerable _communityTokenValidators;
private readonly ILogger _logger;
public DefaultUdapAuthorizationExtensionValidator(
- IOptionsMonitor serverSettings,
IUdapClientRegistrationStore clientStore,
+ IEnumerable communityTokenValidators,
ILogger logger)
{
- _serverSettings = serverSettings;
_clientStore = clientStore;
+ _communityTokenValidators = communityTokenValidators;
_logger = logger;
}
public async Task ValidateAsync(
UdapAuthorizationExtensionValidationContext context)
{
- var resolved = await ResolveCommunitySettingsAsync(context.CommunityId, context.GrantType);
+ var resolved = await ResolveSettingsAsync(context.CommunityId, context.GrantType);
var requiredExtensions = resolved.RequiredExtensions;
- if (requiredExtensions == null || requiredExtensions.Count == 0)
+ if (requiredExtensions != null && requiredExtensions.Count > 0)
{
- return AuthorizationExtensionValidationResult.Success();
- }
-
- if (context.Extensions == null || context.Extensions.Count == 0)
- {
- _logger.LogError(
- "Client {ClientId} did not include required authorization extensions: {Required}",
- context.ClientId, string.Join(", ", requiredExtensions));
-
- return AuthorizationExtensionValidationResult.Failure(
- "invalid_grant",
- $"Required authorization extension(s) missing: {string.Join(", ", requiredExtensions)}");
- }
-
- foreach (var required in requiredExtensions)
- {
- if (!context.Extensions.ContainsKey(required))
+ if (context.Extensions == null || context.Extensions.Count == 0)
{
_logger.LogError(
- "Client {ClientId} missing required authorization extension '{Extension}'",
- context.ClientId, required);
+ "Client {ClientId} did not include required authorization extensions: {Required}",
+ context.ClientId, string.Join(", ", requiredExtensions));
return AuthorizationExtensionValidationResult.Failure(
"invalid_grant",
- $"Required authorization extension '{required}' not found");
+ $"Required authorization extension(s) missing: {string.Join(", ", requiredExtensions)}");
+ }
+
+ foreach (var required in requiredExtensions)
+ {
+ if (!context.Extensions.ContainsKey(required))
+ {
+ _logger.LogError(
+ "Client {ClientId} missing required authorization extension '{Extension}'",
+ context.ClientId, required);
+
+ return AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ $"Required authorization extension '{required}' not found");
+ }
}
}
- foreach (var (key, value) in context.Extensions)
+ // Structural validation of extension objects
+ if (context.Extensions != null)
{
- var errors = ValidateExtensionObject(key, value);
-
- if (errors.Count > 0)
+ foreach (var (key, value) in context.Extensions)
{
- _logger.LogError(
- "Client {ClientId} authorization extension '{Extension}' validation failed: {Errors}",
- context.ClientId, key, string.Join("; ", errors));
+ var errors = ValidateExtensionObject(key, value);
- return AuthorizationExtensionValidationResult.Failure(
- "invalid_grant",
- $"Authorization extension '{key}' validation failed: {string.Join("; ", errors)}");
+ if (errors.Count > 0)
+ {
+ _logger.LogError(
+ "Client {ClientId} authorization extension '{Extension}' validation failed: {Errors}",
+ context.ClientId, key, string.Join("; ", errors));
+
+ return AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ $"Authorization extension '{key}' validation failed: {string.Join("; ", errors)}");
+ }
+
+ var pouResult = ValidatePurposeOfUse(
+ key, value, resolved.AllowedPurposeOfUse, resolved.MaxPurposeOfUseCount,
+ context.ClientId);
+
+ if (!pouResult.IsValid)
+ {
+ return pouResult;
+ }
}
+ }
- var pouResult = ValidatePurposeOfUse(
- key, value, resolved.AllowedPurposeOfUse, resolved.MaxPurposeOfUseCount,
- resolved.IsCommunityResolved, context.ClientId);
+ // Community-specific token validation — always runs regardless of whether
+ // extensions are required, so community validators can enforce additional
+ // rules (e.g., TEFCA purpose_of_use matching against registered SAN URI).
+ if (!string.IsNullOrEmpty(resolved.CommunityName))
+ {
+ context.CommunityName = resolved.CommunityName;
- if (!pouResult.IsValid)
+ foreach (var communityValidator in _communityTokenValidators)
{
- return pouResult;
+ if (communityValidator.AppliesToCommunity(resolved.CommunityName))
+ {
+ var communityResult = await communityValidator.ValidateAsync(context);
+ if (!communityResult.IsValid)
+ {
+ return communityResult;
+ }
+ }
}
}
return AuthorizationExtensionValidationResult.Success();
}
- private async Task ResolveCommunitySettingsAsync(string? communityId, string? grantType)
+ private async Task ResolveSettingsAsync(string? communityId, string? grantType)
{
- var settings = _serverSettings.CurrentValue;
+ string? communityName = null;
- if (communityId != null && settings.CommunitySettings is { Count: > 0 })
+ // Resolve community name from store
+ if (communityId != null)
{
- foreach (var commSettings in settings.CommunitySettings)
- {
- var resolvedId = await _clientStore.GetCommunityId(commSettings.Community);
+ communityName = await _clientStore.GetCommunityName(communityId);
+ }
- if (resolvedId?.ToString() == communityId)
+ // Check if a community validator provides rules for this community
+ if (communityName != null)
+ {
+ foreach (var validator in _communityTokenValidators)
+ {
+ if (validator.AppliesToCommunity(communityName))
{
- return new ResolvedSettings
+ var rules = validator.GetValidationRules(grantType);
+ if (rules != null)
{
- IsCommunityResolved = true,
- RequiredExtensions = ResolveRequiredExtensions(
- grantType,
- commSettings.ClientCredentialsExtensionsRequired,
- commSettings.AuthorizationCodeExtensionsRequired,
- commSettings.AuthorizationExtensionsRequired
- ?? settings.AuthorizationExtensionsRequired),
- AllowedPurposeOfUse = commSettings.AllowedPurposeOfUse,
- MaxPurposeOfUseCount = commSettings.MaxPurposeOfUseCount
- };
+ return new ResolvedSettings
+ {
+ CommunityName = communityName,
+ RequiredExtensions = rules.RequiredExtensions,
+ AllowedPurposeOfUse = rules.AllowedPurposeOfUse,
+ MaxPurposeOfUseCount = rules.MaxPurposeOfUseCount
+ };
+ }
}
}
}
+ // No community validator matched — no extension or POU enforcement
return new ResolvedSettings
{
- RequiredExtensions = ResolveRequiredExtensions(
- grantType,
- settings.ClientCredentialsExtensionsRequired,
- settings.AuthorizationCodeExtensionsRequired,
- settings.AuthorizationExtensionsRequired),
- AllowedPurposeOfUse = settings.AllowedPurposeOfUse,
- MaxPurposeOfUseCount = settings.MaxPurposeOfUseCount
+ CommunityName = communityName
};
}
- ///
- /// Resolves the required extensions for the given grant type.
- /// Grant-type-specific settings take precedence over the general fallback.
- ///
- private static HashSet? ResolveRequiredExtensions(
- string? grantType,
- HashSet? clientCredentialsExtensions,
- HashSet? authorizationCodeExtensions,
- HashSet? fallback)
- {
- var grantSpecific = grantType switch
- {
- "client_credentials" => clientCredentialsExtensions,
- "authorization_code" => authorizationCodeExtensions,
- _ => null
- };
-
- return grantSpecific ?? fallback;
- }
-
private AuthorizationExtensionValidationResult ValidatePurposeOfUse(
string extensionKey,
object extensionValue,
HashSet? allowedCodes,
int? maxCount,
- bool isCommunityResolved,
string clientId)
{
var purposeOfUse = GetPurposeOfUse(extensionKey, extensionValue);
@@ -190,17 +193,6 @@ private AuthorizationExtensionValidationResult ValidatePurposeOfUse(
if (allowedCodes == null)
{
- if (isCommunityResolved)
- {
- _logger.LogError(
- "Client {ClientId} extension '{Extension}' contains purpose_of_use but AllowedPurposeOfUse is not configured for the matched community",
- clientId, extensionKey);
-
- return AuthorizationExtensionValidationResult.Failure(
- "server_error",
- $"AllowedPurposeOfUse is not configured for the matched community; extension '{extensionKey}' cannot be validated");
- }
-
return AuthorizationExtensionValidationResult.Success();
}
@@ -243,7 +235,7 @@ private static List ValidateExtensionObject(string key, object value)
private class ResolvedSettings
{
- public bool IsCommunityResolved { get; init; }
+ public string? CommunityName { get; init; }
public HashSet? RequiredExtensions { get; init; }
public HashSet? AllowedPurposeOfUse { get; init; }
public int? MaxPurposeOfUseCount { get; init; }
diff --git a/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs b/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs
index 460c67078..c29cbbe95 100644
--- a/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs
+++ b/Udap.Server/Validation/Default/UdapCustomTokenRequestValidator.cs
@@ -9,6 +9,7 @@
using System.Text.Json;
using Duende.IdentityServer.Validation;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using Udap.Model;
@@ -29,15 +30,18 @@ public class UdapCustomTokenRequestValidator : ICustomTokenRequestValidator
{
private readonly IUdapAuthorizationExtensionValidator _extensionValidator;
private readonly IEnumerable _customDeserializers;
+ private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger _logger;
public UdapCustomTokenRequestValidator(
IUdapAuthorizationExtensionValidator extensionValidator,
IEnumerable customDeserializers,
+ IHttpContextAccessor httpContextAccessor,
ILogger logger)
{
_extensionValidator = extensionValidator;
_customDeserializers = customDeserializers;
+ _httpContextAccessor = httpContextAccessor;
_logger = logger;
}
@@ -81,13 +85,18 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context)
.FirstOrDefault(s => s.Type == UdapServerConstants.SecretTypes.UDAP_COMMUNITY)
?.Value;
+ var sanUri = request.Client.ClientSecrets
+ .FirstOrDefault(s => s.Type == UdapServerConstants.SecretTypes.UDAP_SAN_URI_ISS_NAME)
+ ?.Value;
+
var extensionContext = new UdapAuthorizationExtensionValidationContext
{
ClientAssertionToken = jwtToken,
ClientId = request.ClientId ?? string.Empty,
Extensions = extensions,
CommunityId = communityId,
- GrantType = request.GrantType
+ GrantType = request.GrantType,
+ SanUri = sanUri
};
var result = await _extensionValidator.ValidateAsync(extensionContext);
@@ -98,6 +107,12 @@ public async Task ValidateAsync(CustomTokenRequestValidationContext context)
"Authorization extension validation failed for client_id {ClientId}: {Error}",
request.ClientId, result.ErrorDescription);
+ if (result.ErrorExtensions != null && _httpContextAccessor.HttpContext != null)
+ {
+ _httpContextAccessor.HttpContext.Items[UdapServerConstants.HttpContextItems.UdapErrorExtensions] =
+ result.ErrorExtensions;
+ }
+
context.Result = new TokenRequestValidationResult(
request,
result.Error ?? "invalid_grant",
diff --git a/Udap.Server/Validation/ICommunityTokenValidator.cs b/Udap.Server/Validation/ICommunityTokenValidator.cs
new file mode 100644
index 000000000..cd2849947
--- /dev/null
+++ b/Udap.Server/Validation/ICommunityTokenValidator.cs
@@ -0,0 +1,39 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+namespace Udap.Server.Validation;
+
+///
+/// Community-specific validation of authorization extensions at token time.
+/// Implementations are registered as a DI collection and invoked when
+/// the client belongs to a matching community.
+///
+public interface ICommunityTokenValidator
+{
+ ///
+ /// Returns true if this validator should run for the given community.
+ ///
+ ///
+ /// The community URI string (e.g., urn:oid:2.16.840.1.113883.3.7204.1.5 ).
+ ///
+ bool AppliesToCommunity(string communityName);
+
+ ///
+ /// Returns the validation rules this validator enforces for the given grant type,
+ /// or null to defer to global ServerSettings configuration.
+ /// When non-null, these rules override global settings for the matched community.
+ ///
+ CommunityValidationRules? GetValidationRules(string? grantType) => null;
+
+ ///
+ /// Validates community-specific token-time rules.
+ ///
+ Task ValidateAsync(
+ UdapAuthorizationExtensionValidationContext context);
+}
diff --git a/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs b/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs
index f49190716..705764902 100644
--- a/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs
+++ b/Udap.Server/Validation/UdapAuthorizationExtensionValidationContext.cs
@@ -44,4 +44,17 @@ public class UdapAuthorizationExtensionValidationContext
/// Used to resolve grant-type-specific extension requirements.
///
public string? GrantType { get; set; }
+
+ ///
+ /// The SAN URI the client registered with (from the UDAP_SAN_URI_ISS_NAME secret).
+ /// Community validators use this to enforce that the purpose_of_use matches
+ /// the exchange purpose embedded in the SAN.
+ ///
+ public string? SanUri { get; set; }
+
+ ///
+ /// The community name (URI) the client belongs to.
+ /// Used by implementations to determine applicability.
+ ///
+ public string? CommunityName { get; set; }
}
diff --git a/Udap.Server/docs/README.md b/Udap.Server/docs/README.md
index 29a491633..748cda287 100644
--- a/Udap.Server/docs/README.md
+++ b/Udap.Server/docs/README.md
@@ -2,20 +2,33 @@

-## 📦 Nuget Package: [Udap.Server](https://www.nuget.org/packages?q=udap.server)
+## 📦 Nuget Package: [Udap.Server](https://www.nuget.org/packages/Udap.Server)
-This package is intended to be used with Duende's Identity Server. Duende must be licensed if you are using it for anything more than testing and you make more than 1 million dollars a year. I am willing to build samples using other identity providers. Keep in mind `Udap.Server` is meant to add auto registration only. Then it relies on Identity Server for identity rather than build a complete identity provider server. There is great value Duende provides that would take a very long time to get correct.
+This package adds UDAP Dynamic Client Registration (DCR) and metadata capabilities to an authorization server built on Duende IdentityServer. It provides the `.well-known/udap` metadata endpoint and the `/connect/register` DCR endpoint as extensions to the IdentityServer pipeline.
-This package contains a few extension methods and two endpoints. The first endpoint is an UDAP metadata endpoint, implementing Duende's IEndpointHandler interface allowing /.well-known/udap to render metadata. The items of interest in the metadata is the registration_endpoint which points to the next endpoint; `UdapDynamicClientRegistrationEndpoint`. This code is a simple endpoint called as a delegate using the dotnet minimal api technique. All of this is configured by adding the ```AddUdapServer()``` extension method to the Identity Server pipeline.
+> **Note:** Duende IdentityServer requires a [license](https://duendesoftware.com/products/identityserver) for production use above $1M annual revenue.
-**Assumptions:** An Identity Sever exists is backed by a relational database. Use [Udap.Auth.Server](./../examples/Udap.Auth.Server/) as an example. I may revisit this in the future and build an in memory version but this reference implementation. For now it assumes a relational database is deployed.
+## Features
+
+- UDAP metadata endpoint (`.well-known/udap`)
+- Dynamic Client Registration (create, update, cancel)
+- Multi-community trust anchor support
+- Authorization Extension Object (AEO) enforcement via `IUdapAuthorizationExtensionValidator`
+- Tiered OAuth support
+
+### Profile-Specific Validation
+
+For SSRAA or TEFCA community-specific validation rules, add the corresponding packages:
+
+- [`Udap.Ssraa.Server`](https://www.nuget.org/packages/Udap.Ssraa.Server) — HL7 v3 PurposeOfUse enforcement
+- [`Udap.Tefca.Server`](https://www.nuget.org/packages/Udap.Tefca.Server) — TEFCA Exchange Purpose (XP) code validation, SAN matching
+- [`Udap.Tefca.Model`](https://www.nuget.org/packages/Udap.Tefca.Model) — TEFCA extension models (`tefca-ias`, `tefca_smart`)
## Full Example
-Below is a full example. Alternatively the [2023 FHIR® DevDays Tutorial](udap-devdays-2023) is another great way to learn how to use ```Udap.Server```.
+Below is a full example. See also the [Udap.Auth.Server](../../examples/Udap.Auth.Server/) example project.
```csharp
-
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddIdentityServer()
@@ -28,7 +41,6 @@ builder.Services.AddIdentityServer()
{
options.ConfigureDbContext = b => b.UseSqlite(connectionString,
dbOpts => dbOpts.MigrationsAssembly(migrationsAssembly));
-
})
.AddResourceStore()
.AddClientStore()
@@ -39,8 +51,8 @@ builder.Services.AddIdentityServer()
var udapServerOptions = builder.Configuration.GetOption("ServerSettings");
options.DefaultSystemScopes = udapServerOptions.DefaultSystemScopes;
options.DefaultUserScopes = udapServerOptions.DefaultUserScopes;
- options.ForceStateParamOnAuthorizationCode = udapServerOptions.
- ForceStateParamOnAuthorizationCode;
+ options.ForceStateParamOnAuthorizationCode = udapServerOptions
+ .ForceStateParamOnAuthorizationCode;
},
options =>
options.UdapDbContext = b =>
@@ -50,35 +62,35 @@ builder.Services.AddIdentityServer()
baseUrl: "https://localhost:5002/connect/register"
);
- var app = builder.Build();
+var app = builder.Build();
- // uncomment if you want to add a UI
- app.UseStaticFiles();
- app.UseRouting();
+app.UseStaticFiles();
+app.UseRouting();
- app.UseUdapServer();
- app.UseIdentityServer();
+app.UseUdapServer();
+app.UseIdentityServer();
- // uncomment if you want to add a UI
- app.UseAuthorization();
- app.MapRazorPages().RequireAuthorization();
-
- app.Run;
+app.UseAuthorization();
+app.MapRazorPages().RequireAuthorization();
+app.Run();
```
-## Udap.Auth.Server Database Configuration
+## Database Configuration
-For your convenience a EF Migrations Project called [UdapDb.SqlServer](/migrations/UdapDb.SqlServer/) can deploy the database schema. Run from Visual Studio using the UdapDb profile (/properties/launchSettings.json). This project will create all the Udap tables and Duende Identity tables. It will seed data needed for running local system tests. See the SeedData.cs for details.
+EF Core migration projects are available for both database providers:
-If you need another database such as PostgreSQL I could be motivated to create one.
+- [UdapDb.SqlServer](../../migrations/UdapDb.SqlServer/) — SQL Server migrations
+- [UdapDb.Postgres](../../migrations/UdapDb.Postgres/) — PostgreSQL migrations
-Not the [UdapDb.SqlServer](/migrations/UdapDb.SqlServer/) project includes two migrations for Duende's Identity Server tables. I have not put anytime into migrating a schema. At this point my pattern is to just delete the database and re-create it. At some point I will version this and start migrating officially.
+These projects create all UDAP and Duende IdentityServer tables and seed data needed for running local tests. See `SeedData.cs` for details.
-## UDAP Authorization Server Examples
+## Examples
-- [Udap.Auth.Server](./../examples/Udap.Auth.Server/)
+- [Udap.Auth.Server](../../examples/Udap.Auth.Server/)
- [Udap.Auth.Server Deployed](https://securedcontrols.net/.well-known/udap)
+---
+
- FHIR® is the registered trademark of HL7 and is used with the permission of HL7. Use of the FHIR trademark does not constitute endorsement of the contents of this repository by HL7.
-- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org. UDAP Draft Specifications are referenced and displayed in parts of this source code to document specification implementation.
+- UDAP® and the UDAP gear logo, ecosystem gears, and green lock designs are trademarks of UDAP.org.
diff --git a/Udap.Smart.Metadata/docs/README.md b/Udap.Smart.Metadata/docs/README.md
index 93510b81c..0c04eab91 100644
--- a/Udap.Smart.Metadata/docs/README.md
+++ b/Udap.Smart.Metadata/docs/README.md
@@ -2,6 +2,6 @@

-## 📦 This package
-
+## 📦 Nuget Package: [Udap.Smart.Metadata](https://www.nuget.org/packages/Udap.Smart.Metadata)
+Server-side implementation of the `.well-known/smart-configuration` endpoint per the [HL7 FHIR SMART App Launch v2.1](http://hl7.org/fhir/smart-app-launch/) specification. Returns SMART metadata including OAuth endpoints, scopes, capabilities, and authentication methods.
diff --git a/Udap.Smart.Model/docs/README.md b/Udap.Smart.Model/docs/README.md
index 6875461af..c15c4300a 100644
--- a/Udap.Smart.Model/docs/README.md
+++ b/Udap.Smart.Model/docs/README.md
@@ -2,6 +2,6 @@

-## 📦 This package
-
+## 📦 Nuget Package: [Udap.Smart.Model](https://www.nuget.org/packages/Udap.Smart.Model)
+Data models for FHIR SMART App Launch metadata configuration based on the [HL7 FHIR SMART App Launch v2.1](http://hl7.org/fhir/smart-app-launch/) specification. Includes the `SmartMetadata` class defining authorization endpoints, token endpoints, grant types, scopes, and capabilities.
diff --git a/Udap.Ssraa.Server/ServiceCollectionExtensions.cs b/Udap.Ssraa.Server/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..1e56c96a4
--- /dev/null
+++ b/Udap.Ssraa.Server/ServiceCollectionExtensions.cs
@@ -0,0 +1,41 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using Microsoft.Extensions.DependencyInjection;
+using Udap.Server.Validation;
+using Udap.Ssraa.Server;
+
+// ReSharper disable once CheckNamespace
+namespace Microsoft.Extensions.DependencyInjection;
+
+public static class SsraaServiceCollectionExtensions
+{
+ ///
+ /// Registers the SSRAA community token validator with default options.
+ ///
+ public static IServiceCollection AddUdapSsraaValidation(
+ this IServiceCollection services)
+ {
+ return services.AddUdapSsraaValidation(_ => { });
+ }
+
+ ///
+ /// Registers the SSRAA community token validator with configurable options.
+ /// Use the configure action to add community names and customize required extensions.
+ ///
+ public static IServiceCollection AddUdapSsraaValidation(
+ this IServiceCollection services,
+ Action configure)
+ {
+ services.Configure(configure);
+ services.AddSingleton();
+
+ return services;
+ }
+}
diff --git a/Udap.Ssraa.Server/SsraaTokenValidator.cs b/Udap.Ssraa.Server/SsraaTokenValidator.cs
new file mode 100644
index 000000000..31b050404
--- /dev/null
+++ b/Udap.Ssraa.Server/SsraaTokenValidator.cs
@@ -0,0 +1,137 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using Microsoft.Extensions.Options;
+using Udap.Server.Validation;
+
+namespace Udap.Ssraa.Server;
+
+///
+/// Validates standard SSRAA community token request rules:
+///
+/// 1. Enforces required extensions per grant type (configurable via ).
+/// 2. Validates purpose_of_use codes against the HL7 v3 PurposeOfUse value set
+/// (OID: 2.16.840.1.113883.5.8).
+///
+///
+/// HL7 v3 PurposeOfUse Value Set
+///
+public class SsraaTokenValidator : ICommunityTokenValidator
+{
+ private readonly SsraaValidationOptions _options;
+
+ ///
+ /// HL7 v3 PurposeOfUse value set (OID: 2.16.840.1.113883.5.8).
+ ///
+ /// HL7 v3 PurposeOfUse Value Set
+ ///
+ internal static readonly HashSet AllHl7V3PurposeOfUseCodes = new(StringComparer.Ordinal)
+ {
+ "urn:oid:2.16.840.1.113883.5.8#TREAT",
+ "urn:oid:2.16.840.1.113883.5.8#ETREAT",
+ "urn:oid:2.16.840.1.113883.5.8#BTG",
+ "urn:oid:2.16.840.1.113883.5.8#ERTREAT",
+ "urn:oid:2.16.840.1.113883.5.8#COC",
+ "urn:oid:2.16.840.1.113883.5.8#CLINTRL",
+ "urn:oid:2.16.840.1.113883.5.8#POPHLTH",
+ "urn:oid:2.16.840.1.113883.5.8#TREATDS",
+ "urn:oid:2.16.840.1.113883.5.8#HPAYMT",
+ "urn:oid:2.16.840.1.113883.5.8#CLMATTCH",
+ "urn:oid:2.16.840.1.113883.5.8#COVAUTH",
+ "urn:oid:2.16.840.1.113883.5.8#COVERAGE",
+ "urn:oid:2.16.840.1.113883.5.8#ELIGDTRM",
+ "urn:oid:2.16.840.1.113883.5.8#ELIGVER",
+ "urn:oid:2.16.840.1.113883.5.8#ENROLLM",
+ "urn:oid:2.16.840.1.113883.5.8#MILDCRG",
+ "urn:oid:2.16.840.1.113883.5.8#REMITADV",
+ "urn:oid:2.16.840.1.113883.5.8#PMTDS",
+ "urn:oid:2.16.840.1.113883.5.8#HOPERAT",
+ "urn:oid:2.16.840.1.113883.5.8#CAREMGT",
+ "urn:oid:2.16.840.1.113883.5.8#DONAT",
+ "urn:oid:2.16.840.1.113883.5.8#FRAUD",
+ "urn:oid:2.16.840.1.113883.5.8#GOV",
+ "urn:oid:2.16.840.1.113883.5.8#HACCRED",
+ "urn:oid:2.16.840.1.113883.5.8#HCOMPL",
+ "urn:oid:2.16.840.1.113883.5.8#HDECD",
+ "urn:oid:2.16.840.1.113883.5.8#HDIRECT",
+ "urn:oid:2.16.840.1.113883.5.8#HDM",
+ "urn:oid:2.16.840.1.113883.5.8#HLEGAL",
+ "urn:oid:2.16.840.1.113883.5.8#HOUTCOMS",
+ "urn:oid:2.16.840.1.113883.5.8#HPRGRP",
+ "urn:oid:2.16.840.1.113883.5.8#HQUALIMP",
+ "urn:oid:2.16.840.1.113883.5.8#HSYSADMIN",
+ "urn:oid:2.16.840.1.113883.5.8#LABELING",
+ "urn:oid:2.16.840.1.113883.5.8#METAMGT",
+ "urn:oid:2.16.840.1.113883.5.8#MEMADMIN",
+ "urn:oid:2.16.840.1.113883.5.8#MILCDM",
+ "urn:oid:2.16.840.1.113883.5.8#PATADMIN",
+ "urn:oid:2.16.840.1.113883.5.8#PATSFTY",
+ "urn:oid:2.16.840.1.113883.5.8#PERFMSR",
+ "urn:oid:2.16.840.1.113883.5.8#RECORDMGT",
+ "urn:oid:2.16.840.1.113883.5.8#SYSDEV",
+ "urn:oid:2.16.840.1.113883.5.8#HTEST",
+ "urn:oid:2.16.840.1.113883.5.8#TRAIN",
+ "urn:oid:2.16.840.1.113883.5.8#MLTRAINING",
+ "urn:oid:2.16.840.1.113883.5.8#HRESCH",
+ "urn:oid:2.16.840.1.113883.5.8#BIORCH",
+ "urn:oid:2.16.840.1.113883.5.8#CLINTRCH",
+ "urn:oid:2.16.840.1.113883.5.8#CLINTRCHNPC",
+ "urn:oid:2.16.840.1.113883.5.8#CLINTRCHPC",
+ "urn:oid:2.16.840.1.113883.5.8#PRECLINTRCH",
+ "urn:oid:2.16.840.1.113883.5.8#DSRCH",
+ "urn:oid:2.16.840.1.113883.5.8#POARCH",
+ "urn:oid:2.16.840.1.113883.5.8#TRANSRCH",
+ "urn:oid:2.16.840.1.113883.5.8#PATRQT",
+ "urn:oid:2.16.840.1.113883.5.8#FAMRQT",
+ "urn:oid:2.16.840.1.113883.5.8#PWATRNY",
+ "urn:oid:2.16.840.1.113883.5.8#SUPNWK",
+ "urn:oid:2.16.840.1.113883.5.8#PUBHLTH",
+ "urn:oid:2.16.840.1.113883.5.8#DISASTER",
+ "urn:oid:2.16.840.1.113883.5.8#THREAT",
+ "urn:oid:2.16.840.1.113883.5.8#HMARKT",
+ };
+
+ public SsraaTokenValidator(IOptions options)
+ {
+ _options = options.Value;
+ }
+
+ ///
+ public bool AppliesToCommunity(string communityName)
+ => _options.Communities.Contains(communityName);
+
+ ///
+ /// Returns SSRAA validation rules for the given grant type.
+ /// Required extensions are configurable via .
+ /// AllowedPurposeOfUse is the full HL7 v3 PurposeOfUse value set.
+ ///
+ public CommunityValidationRules? GetValidationRules(string? grantType)
+ {
+ var requiredExtensions = grantType switch
+ {
+ "client_credentials" => _options.ClientCredentialsExtensionsRequired,
+ "authorization_code" => _options.AuthorizationCodeExtensionsRequired,
+ _ => null
+ };
+
+ return new CommunityValidationRules
+ {
+ RequiredExtensions = requiredExtensions,
+ AllowedPurposeOfUse = AllHl7V3PurposeOfUseCodes,
+ MaxPurposeOfUseCount = null // SSRAA does not limit purpose_of_use count
+ };
+ }
+
+ ///
+ public Task ValidateAsync(
+ UdapAuthorizationExtensionValidationContext context)
+ {
+ return Task.FromResult(AuthorizationExtensionValidationResult.Success());
+ }
+}
diff --git a/Udap.Ssraa.Server/SsraaValidationOptions.cs b/Udap.Ssraa.Server/SsraaValidationOptions.cs
new file mode 100644
index 000000000..d78d6711c
--- /dev/null
+++ b/Udap.Ssraa.Server/SsraaValidationOptions.cs
@@ -0,0 +1,35 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+namespace Udap.Ssraa.Server;
+
+///
+/// Configuration options for the SSRAA community token validator.
+/// Maps community names to the SSRAA validation pipeline.
+///
+public class SsraaValidationOptions
+{
+ ///
+ /// Community names that should use SSRAA validation rules.
+ ///
+ public HashSet Communities { get; set; } = new(StringComparer.Ordinal);
+
+ ///
+ /// Extension keys required for client_credentials token requests (e.g., ["hl7-b2b"]).
+ /// Default: hl7-b2b required.
+ ///
+ public HashSet? ClientCredentialsExtensionsRequired { get; set; } =
+ [Udap.Model.UdapConstants.UdapAuthorizationExtensions.Hl7B2B];
+
+ ///
+ /// Extension keys required for authorization_code token requests.
+ /// Default: none required.
+ ///
+ public HashSet? AuthorizationCodeExtensionsRequired { get; set; }
+}
diff --git a/Udap.Ssraa.Server/Udap.Ssraa.Server.csproj b/Udap.Ssraa.Server/Udap.Ssraa.Server.csproj
new file mode 100644
index 000000000..bcc2e4c95
--- /dev/null
+++ b/Udap.Ssraa.Server/Udap.Ssraa.Server.csproj
@@ -0,0 +1,44 @@
+
+
+
+ net8.0;net9.0;net10.0
+ enable
+ latest
+ enable
+
+ git
+ https://github.com/JoeShook/udap-dotnet
+ README.md
+ Joseph Shook
+ Copyright © Joseph.Shook@Surescripts.com 2026
+ MIT
+ true
+ UDAP_Ecosystem_Gears 48X48.jpg
+ UDAP;FHIR;HL7;SSRAA
+ SSRAA community-specific validators for UDAP token issuance with HL7 v3 PurposeOfUse enforcement.
+
+
+
+
+ \
+ true
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/Udap.Ssraa.Server/docs/README.md b/Udap.Ssraa.Server/docs/README.md
new file mode 100644
index 000000000..11fd4dfa0
--- /dev/null
+++ b/Udap.Ssraa.Server/docs/README.md
@@ -0,0 +1,3 @@
+## Udap.Ssraa.Server
+
+SSRAA community-specific validators for UDAP token issuance with HL7 v3 PurposeOfUse enforcement.
diff --git a/Udap.Tefca.Model/TEFCAAuthorizationErrorExtension.cs b/Udap.Tefca.Model/TEFCAAuthorizationErrorExtension.cs
new file mode 100644
index 000000000..c30d271bb
--- /dev/null
+++ b/Udap.Tefca.Model/TEFCAAuthorizationErrorExtension.cs
@@ -0,0 +1,46 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Udap.Tefca.Model;
+
+///
+/// TEFCA Authorization Extension Error Object returned in the "extensions" object
+/// of an invalid_grant error response when the data holder determines that the
+/// authorization metadata submitted is insufficient because the requestor has
+/// omitted the ACP parameter or has asserted a policy that is not acceptable.
+///
+/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 B2B #3, Table 1
+///
+public class TEFCAAuthorizationErrorExtension
+{
+ ///
+ /// consent_required (required):
+ /// The list of acceptable Access Consent Policy Identifier(s)
+ /// corresponding to the asserted Access Policy required for
+ /// authorization, an array of string values from the list of valid policy
+ /// OIDs each expressed as a URI.
+ ///
+ /// SOP v2.0 — Table 1
+ ///
+ [JsonPropertyName("consent_required")]
+ public ICollection ConsentRequired { get; set; } = [];
+
+ ///
+ /// consent_form (optional):
+ /// A URL as a string where the required consent form may be
+ /// downloaded, if applicable.
+ ///
+ /// SOP v2.0 — Table 1
+ ///
+ [JsonPropertyName("consent_form")]
+ public string? ConsentForm { get; set; }
+}
diff --git a/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs b/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs
index d58e88bfd..c176193fc 100644
--- a/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs
+++ b/Udap.Tefca.Model/TEFCAIASAuthorizationExtension.cs
@@ -17,18 +17,16 @@ namespace Udap.Tefca.Model;
///
/// TEFCA IAS Authorization Extension Object.
///
-/// TEFCA IAS AUTHORIZATION EXTENSION OBJECT
+/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 IAS, Table 4
///
public class TEFCAIASAuthorizationExtension : IAuthorizationExtensionObject
{
private string _version = "1";
private JsonElement? _userInformation;
private JsonElement? _patientInformation;
- private string _purposeOfUse = TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUseCode;
private ICollection? _consentPolicy;
private ICollection? _consentReference;
private JsonElement? _idToken;
- private JsonElement? _ialVetted;
public TEFCAIASAuthorizationExtension()
{
@@ -49,18 +47,6 @@ public string Version
set => _version = value;
}
- ///
- /// purpose_of_use required:
- ///
- /// Fixed Value "T-IAS".
- ///
- [JsonPropertyName(TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUse)]
- public string PurposeOfUse
- {
- get => _purposeOfUse;
- set => _purposeOfUse = value;
- }
-
///
/// user_information required:
///
@@ -119,9 +105,13 @@ public ICollection? ConsentReference
}
///
- /// id_token optional:
+ /// id_token required:
///
- /// Additional token as per relevant SOP
+ /// The CSP-provided OpenID Connect token as further defined in
+ /// the Exchange Purpose (XP) Implementation SOP: Individual Access Services (IAS).
+ /// Responding server SHOULD respond with invalid_grant if missing.
+ ///
+ /// SOP v2.0 — Table 4
///
[JsonPropertyName(TefcaConstants.TEFCAIASAuthorizationExtension.IdToken)]
public JsonElement? IdToken
@@ -130,19 +120,6 @@ public JsonElement? IdToken
set => _idToken = value;
}
- ///
- /// ial_vetted conditional:
- ///
- /// OIDC token provided by Identity Verifier when the Identity Verifier is not
- /// the Responding Node. Responding server MAY respond with invalid_grant if missing.
- ///
- [JsonPropertyName(TefcaConstants.TEFCAIASAuthorizationExtension.IalVetted)]
- public JsonElement? IalVetted
- {
- get => _ialVetted;
- set => _ialVetted = value;
- }
-
///
public List Validate()
{
@@ -163,11 +140,6 @@ public List Validate()
notes.Add($"Missing required {TefcaConstants.TEFCAIASAuthorizationExtension.PatientInformation}");
}
- if (PurposeOfUse != TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUseCode)
- {
- notes.Add($"{TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUse} must be {TefcaConstants.TEFCAIASAuthorizationExtension.PurposeOfUseCode}");
- }
-
return notes;
}
diff --git a/Udap.Tefca.Model/TefcaCertificationDocument.cs b/Udap.Tefca.Model/TefcaCertificationDocument.cs
new file mode 100644
index 000000000..605cc29fe
--- /dev/null
+++ b/Udap.Tefca.Model/TefcaCertificationDocument.cs
@@ -0,0 +1,54 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Udap.Model.Registration;
+
+namespace Udap.Tefca.Model;
+
+///
+/// TEFCA-specific certification document extending the base UDAP certification
+/// with claims required by the Facilitated FHIR Implementation SOP.
+///
+/// The basic-app-certification JWT MUST contain:
+/// - certification_name: "TEFCA Basic App Certification"
+/// - certification_uris: ["https://rce.sequoiaproject.org/udap/profiles/basic-app-certification"]
+/// - exchange_purposes: Array of one Exchange Purpose from the TEFCA Exchange Purposes SOP
+/// - home_community_id: The HomeCommunityId of the Node making the registration request
+///
+/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 Registration #6
+///
+public class TefcaCertificationDocument : UdapCertificationAndEndorsementDocument
+{
+ public TefcaCertificationDocument()
+ : base(TefcaConstants.Certification.BasicAppCertificationName)
+ {
+ CertificationUris = [TefcaConstants.Certification.BasicAppCertificationUri];
+ }
+
+ ///
+ /// An array of one Exchange Purpose from the TEFCA Exchange Purposes SOP.
+ ///
+ [JsonPropertyName(TefcaConstants.Certification.ExchangePurposes)]
+ public ICollection? ExchangePurposes { get; set; }
+
+ ///
+ /// The HomeCommunityId of the Node making the registration request.
+ ///
+ [JsonPropertyName(TefcaConstants.Certification.HomeCommunityId)]
+ public string? HomeCommunityId { get; set; }
+
+ ///
+ public override string SerializeToJson()
+ {
+ return JsonSerializer.Serialize(this);
+ }
+}
diff --git a/Udap.Tefca.Model/TefcaConstants.cs b/Udap.Tefca.Model/TefcaConstants.cs
index 570fdbe29..60e23f48f 100644
--- a/Udap.Tefca.Model/TefcaConstants.cs
+++ b/Udap.Tefca.Model/TefcaConstants.cs
@@ -11,7 +11,8 @@ namespace Udap.Tefca.Model;
///
/// Constants specific to the TEFCA trust community profile.
-/// See SOP: Facilitated FHIR Implementation and SOP: Exchange Purposes (XPs).
+///
+/// SOP: Facilitated FHIR Implementation v2.0
///
public static class TefcaConstants
{
@@ -19,6 +20,8 @@ public static class UdapAuthorizationExtensions
{
///
/// TEFCA IAS Authorization Extension Object key name.
+ ///
+ /// SOP v2.0 — Section 6.11 IAS, Table 4
///
public const string TEFCAIAS = "tefca-ias";
@@ -28,16 +31,24 @@ public static class UdapAuthorizationExtensions
public const string TEFCASMART = "tefca_smart";
}
+ ///
+ /// Field name constants for the TEFCA IAS Authorization Extension Object.
+ ///
+ /// SOP v2.0 — Section 6.11 IAS, Table 4
+ ///
public static class TEFCAIASAuthorizationExtension
{
+ /// Fixed string value: "1". Table 4
public const string Version = "version";
- public const string PurposeOfUse = "purpose_of_use";
- public const string PurposeOfUseCode = "T-IAS";
+ /// FHIR RelatedPerson Resource with all known demographics. Table 4
public const string UserInformation = "user_information";
+ /// FHIR US Core Patient Resource with all known demographics. Table 4
public const string PatientInformation = "patient_information";
- public const string IalVetted = "ial_vetted";
+ /// Access Consent Policy Identifier for identity proofing level of assurance. Table 4
public const string ConsentPolicy = "consent_policy";
+ /// Array of FHIR DocumentReference or Consent resource URLs. Table 4
public const string ConsentReference = "consent_reference";
+ /// The CSP-provided OpenID Connect token. Table 4
public const string IdToken = "id_token";
}
@@ -52,6 +63,8 @@ public static class TEFCASMARTAuthorizationExtension
///
/// TEFCA Exchange Purpose (XP) codes OID: 2.16.840.1.113883.3.7204.1.5.2.1
+ ///
+ /// SOP: Exchange Purposes (XPs) v4.0 — Table 1
///
public static class ExchangePurposeCodes
{
@@ -73,13 +86,18 @@ public static class ExchangePurposeCodes
///
/// TEFCA certification constants.
- /// See SOP: Facilitated FHIR Implementation Section 6.11 Registration #6.
+ ///
+ /// SOP v2.0 — Section 6.11 Registration #6
///
public static class Certification
{
+ /// URI identifying the TEFCA Basic App Certification program. Section 6.11 #2, #3
public const string BasicAppCertificationUri = "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification";
+ /// Fixed certification_name value. Section 6.11 Registration #6
public const string BasicAppCertificationName = "TEFCA Basic App Certification";
+ /// Array of one Exchange Purpose from the TEFCA Exchange Purposes SOP. Section 6.11 Registration #6
public const string ExchangePurposes = "exchange_purposes";
+ /// HomeCommunityId of the Node making the registration request. Section 6.11 Registration #6
public const string HomeCommunityId = "home_community_id";
}
diff --git a/Udap.Tefca.Server/ServiceCollectionExtensions.cs b/Udap.Tefca.Server/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..ea96b6e0a
--- /dev/null
+++ b/Udap.Tefca.Server/ServiceCollectionExtensions.cs
@@ -0,0 +1,51 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using Microsoft.Extensions.DependencyInjection;
+using Udap.Server.Registration;
+using Udap.Server.Validation;
+
+namespace Udap.Tefca.Server;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers TEFCA community-specific validators for registration and token issuance
+ /// using default options (applies to ).
+ /// Call after AddUdapServerConfiguration() and AddUdapJwtBearerClientAuthentication() .
+ ///
+ public static IServiceCollection AddUdapTefcaValidation(this IServiceCollection services)
+ {
+ return services.AddUdapTefcaValidation(_ => { });
+ }
+
+ ///
+ /// Registers TEFCA community-specific validators for registration and token issuance.
+ /// Use the configure action to map additional community names to the TEFCA validation pipeline.
+ /// Call after AddUdapServerConfiguration() and AddUdapJwtBearerClientAuthentication() .
+ ///
+ ///
+ ///
+ /// builder.Services.AddUdapTefcaValidation(options =>
+ /// {
+ /// options.Communities.Add("tefca://test-community");
+ /// });
+ ///
+ ///
+ public static IServiceCollection AddUdapTefcaValidation(
+ this IServiceCollection services,
+ Action configure)
+ {
+ services.Configure(configure);
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services;
+ }
+}
diff --git a/Udap.Tefca.Server/TefcaRegistrationValidator.cs b/Udap.Tefca.Server/TefcaRegistrationValidator.cs
new file mode 100644
index 000000000..2cc673906
--- /dev/null
+++ b/Udap.Tefca.Server/TefcaRegistrationValidator.cs
@@ -0,0 +1,88 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using Microsoft.Extensions.Options;
+using Udap.Model.Registration;
+using Udap.Server.Registration;
+using Udap.Tefca.Model;
+
+namespace Udap.Tefca.Server;
+
+///
+/// Validates that a TEFCA community registration uses a SAN URI containing
+/// a valid Exchange Purpose (XP) code from the TEFCA Exchange Purposes SOP.
+///
+///
+/// SOP v2.0 — Section 6.11 Registration #5a
+///
+public class TefcaRegistrationValidator : ICommunityRegistrationValidator
+{
+ private readonly TefcaValidationOptions _options;
+
+ public TefcaRegistrationValidator(IOptions options)
+ {
+ _options = options.Value;
+ }
+
+ private static readonly HashSet ValidExchangePurposes = new(StringComparer.Ordinal)
+ {
+ TefcaConstants.ExchangePurposeCodes.Treatment,
+ TefcaConstants.ExchangePurposeCodes.TefcaRequiredTreatment,
+ TefcaConstants.ExchangePurposeCodes.Payment,
+ TefcaConstants.ExchangePurposeCodes.HealthCareOperations,
+ TefcaConstants.ExchangePurposeCodes.CareCoordination,
+ TefcaConstants.ExchangePurposeCodes.HedisReporting,
+ TefcaConstants.ExchangePurposeCodes.QualityMeasureReporting,
+ TefcaConstants.ExchangePurposeCodes.PublicHealth,
+ TefcaConstants.ExchangePurposeCodes.ElectronicCaseReporting,
+ TefcaConstants.ExchangePurposeCodes.ElectronicLabReporting,
+ TefcaConstants.ExchangePurposeCodes.IndividualAccessServices,
+ TefcaConstants.ExchangePurposeCodes.GovernmentBenefitsDetermination,
+ };
+
+ ///
+ public bool AppliesToCommunity(string communityName)
+ => _options.Communities.Contains(communityName);
+
+ ///
+ public Task ValidateAsync(
+ UdapDynamicClientRegistrationContext context)
+ {
+ var issuer = context.Issuer;
+
+ if (string.IsNullOrEmpty(issuer))
+ {
+ return Task.FromResult(
+ new UdapDynamicClientRegistrationValidationResult(
+ UdapDynamicClientRegistrationErrors.InvalidSoftwareStatement,
+ "TEFCA registration requires a SAN URI with an exchange purpose"));
+ }
+
+ var hashIndex = issuer.LastIndexOf('#');
+ if (hashIndex < 0 || hashIndex == issuer.Length - 1)
+ {
+ return Task.FromResult(
+ new UdapDynamicClientRegistrationValidationResult(
+ UdapDynamicClientRegistrationErrors.InvalidSoftwareStatement,
+ "TEFCA SAN URI must contain an exchange purpose code after '#'"));
+ }
+
+ var xpCode = issuer.Substring(hashIndex + 1);
+
+ if (!ValidExchangePurposes.Contains(xpCode))
+ {
+ return Task.FromResult(
+ new UdapDynamicClientRegistrationValidationResult(
+ UdapDynamicClientRegistrationErrors.InvalidSoftwareStatement,
+ $"TEFCA SAN URI contains invalid exchange purpose code '{xpCode}'"));
+ }
+
+ return Task.FromResult(null);
+ }
+}
diff --git a/Udap.Tefca.Server/TefcaTokenValidator.cs b/Udap.Tefca.Server/TefcaTokenValidator.cs
new file mode 100644
index 000000000..d79dc49af
--- /dev/null
+++ b/Udap.Tefca.Server/TefcaTokenValidator.cs
@@ -0,0 +1,162 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using Microsoft.Extensions.Options;
+using Udap.Model;
+using Udap.Model.UdapAuthenticationExtensions;
+using Udap.Server.Validation;
+using Udap.Tefca.Model;
+
+namespace Udap.Tefca.Server;
+
+///
+/// Validates TEFCA-specific token request rules per SOP v2.0 Section 6.11:
+///
+/// 1. Declares required extensions per grant type (hl7-b2b for client_credentials,
+/// none for authorization_code).
+/// 2. Enforces allowed purpose_of_use codes from the TEFCA Exchange Purposes SOP v4.0.
+/// 3. Enforces max 1 purpose_of_use entry per Table 2 ("A length 1 array").
+/// 4. The purpose_of_use in the hl7-b2b AEO must match the exchange purpose
+/// coded in the client's registered SAN URI.
+/// 5. If the registered exchange purpose is T-IAS and the grant type is
+/// client_credentials , the tefca_ias AEO must be present.
+///
+///
+/// SOP v2.0 — Table 2 and IAS Queries
+///
+public class TefcaTokenValidator : ICommunityTokenValidator
+{
+ private readonly TefcaValidationOptions _options;
+
+ ///
+ /// All 12 TEFCA Exchange Purpose codes in full OID URI format.
+ ///
+ /// SOP: Exchange Purposes (XPs) v4.0 — Table 1
+ ///
+ internal static readonly HashSet AllTefcaXpCodes = new(StringComparer.Ordinal)
+ {
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.Treatment}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.TefcaRequiredTreatment}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.Payment}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.HealthCareOperations}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.CareCoordination}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.HedisReporting}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.QualityMeasureReporting}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.PublicHealth}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.ElectronicCaseReporting}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.ElectronicLabReporting}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.IndividualAccessServices}",
+ $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#{TefcaConstants.ExchangePurposeCodes.GovernmentBenefitsDetermination}",
+ };
+
+ public TefcaTokenValidator(IOptions options)
+ {
+ _options = options.Value;
+ }
+
+ ///
+ public bool AppliesToCommunity(string communityName)
+ => _options.Communities.Contains(communityName);
+
+ ///
+ /// Returns TEFCA SOP v2.0 validation rules for the given grant type.
+ ///
+ /// client_credentials : hl7-b2b required, max 1 purpose_of_use from TEFCA XP codes
+ /// authorization_code : no extensions required (per spec), same POU rules if extensions are present
+ ///
+ ///
+ public CommunityValidationRules? GetValidationRules(string? grantType)
+ {
+ var requiredExtensions = grantType switch
+ {
+ "client_credentials" => new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
+ "authorization_code" => [],
+ _ => null
+ };
+
+ return new CommunityValidationRules
+ {
+ RequiredExtensions = requiredExtensions,
+ AllowedPurposeOfUse = AllTefcaXpCodes,
+ MaxPurposeOfUseCount = 1
+ };
+ }
+
+ ///
+ public Task ValidateAsync(
+ UdapAuthorizationExtensionValidationContext context)
+ {
+ if (string.IsNullOrEmpty(context.SanUri))
+ {
+ return Task.FromResult(AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ "TEFCA client has no registered SAN URI"));
+ }
+
+ var hashIndex = context.SanUri.LastIndexOf('#');
+ if (hashIndex < 0 || hashIndex == context.SanUri.Length - 1)
+ {
+ return Task.FromResult(AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ "TEFCA client's registered SAN URI does not contain an exchange purpose"));
+ }
+
+ var registeredXp = context.SanUri.Substring(hashIndex + 1);
+
+ // IAS + client_credentials requires tefca_ias AEO (SOP v2.0 Section 6.11, IAS Queries #3)
+ if (string.Equals(registeredXp, TefcaConstants.ExchangePurposeCodes.IndividualAccessServices, StringComparison.Ordinal)
+ && string.Equals(context.GrantType, "client_credentials", StringComparison.OrdinalIgnoreCase))
+ {
+ if (context.Extensions == null
+ || !context.Extensions.ContainsKey(TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS))
+ {
+ return Task.FromResult(AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ "TEFCA IAS client_credentials token request requires the 'tefca_ias' authorization extension"));
+ }
+ }
+
+ if (context.Extensions == null || context.Extensions.Count == 0)
+ {
+ return Task.FromResult(AuthorizationExtensionValidationResult.Success());
+ }
+
+ foreach (var (key, value) in context.Extensions)
+ {
+ if (value is IAuthorizationExtensionObject extObj)
+ {
+ var purposeOfUse = extObj.GetPurposeOfUse();
+ if (purposeOfUse == null || purposeOfUse.Count == 0)
+ {
+ continue;
+ }
+
+ foreach (var code in purposeOfUse)
+ {
+ // Extract XP code from full URI format: urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT
+ var pouCode = code;
+ var pouHashIndex = code.LastIndexOf('#');
+ if (pouHashIndex >= 0 && pouHashIndex < code.Length - 1)
+ {
+ pouCode = code.Substring(pouHashIndex + 1);
+ }
+
+ if (!string.Equals(pouCode, registeredXp, StringComparison.Ordinal))
+ {
+ return Task.FromResult(AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ $"purpose_of_use '{pouCode}' does not match registered exchange purpose '{registeredXp}'"));
+ }
+ }
+ }
+ }
+
+ return Task.FromResult(AuthorizationExtensionValidationResult.Success());
+ }
+}
diff --git a/Udap.Tefca.Server/TefcaValidationOptions.cs b/Udap.Tefca.Server/TefcaValidationOptions.cs
new file mode 100644
index 000000000..3df8cec9e
--- /dev/null
+++ b/Udap.Tefca.Server/TefcaValidationOptions.cs
@@ -0,0 +1,28 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+namespace Udap.Tefca.Server;
+
+///
+/// Configuration options for TEFCA community validators.
+/// Maps community names to the TEFCA validation pipeline,
+/// allowing the same validators to apply to multiple communities
+/// regardless of their naming convention.
+///
+public class TefcaValidationOptions
+{
+ ///
+ /// Community names that should use TEFCA validation rules.
+ /// Defaults to .
+ ///
+ public HashSet Communities { get; set; } = new(StringComparer.Ordinal)
+ {
+ Udap.Tefca.Model.TefcaConstants.CommunityUri
+ };
+}
diff --git a/Udap.Tefca.Server/Udap.Tefca.Server.csproj b/Udap.Tefca.Server/Udap.Tefca.Server.csproj
new file mode 100644
index 000000000..083051217
--- /dev/null
+++ b/Udap.Tefca.Server/Udap.Tefca.Server.csproj
@@ -0,0 +1,45 @@
+
+
+
+ net8.0;net9.0;net10.0
+ enable
+ latest
+ enable
+
+ git
+ https://github.com/JoeShook/udap-dotnet
+ README.md
+ Joseph Shook
+ Copyright © Joseph.Shook@Surescripts.com 2026
+ MIT
+ true
+ UDAP_Ecosystem_Gears 48X48.jpg
+ UDAP;FHIR;HL7;TEFCA
+ TEFCA community-specific validators for UDAP registration and token issuance.
+
+
+
+
+ \
+ true
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/Udap.Tefca.Server/docs/README.md b/Udap.Tefca.Server/docs/README.md
new file mode 100644
index 000000000..8bca1d2d9
--- /dev/null
+++ b/Udap.Tefca.Server/docs/README.md
@@ -0,0 +1,7 @@
+# Udap.Tefca.Server
+
+TEFCA community-specific validators for UDAP registration and token issuance.
+
+Implements `ICommunityRegistrationValidator` and `ICommunityTokenValidator` to enforce
+TEFCA SOP: Facilitated FHIR Implementation v2.0 requirements for exchange purpose
+validation during client registration and token requests.
diff --git a/Udap.UI/docs/README.md b/Udap.UI/docs/README.md
index 04b4a14d0..76a1cd9ae 100644
--- a/Udap.UI/docs/README.md
+++ b/Udap.UI/docs/README.md
@@ -1,7 +1,9 @@
-# Udap.Util
+# Udap.UI

-## 📦 This package
+## 📦 Nuget Package: [Udap.UI](https://www.nuget.org/packages/Udap.UI)
-Include this package in server that already includes the Udap.Server package. It adds razor pages needed for Duende IdentityServer and a few extra pages for UDAP Tiered OAuth.
+This package provides Razor Pages and static assets for the authentication and consent UI used with Duende IdentityServer and UDAP. It includes pages for login, logout, consent, device authorization, CIBA, and grant management, along with support for UDAP Tiered OAuth flows.
+
+Include this package in a server that already includes the `Udap.Server` package.
diff --git a/Udap.sln b/Udap.sln
index d7f475142..1d154d49d 100644
--- a/Udap.sln
+++ b/Udap.sln
@@ -56,10 +56,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.Model", "Udap.Model\Ud
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.Util", "Udap.Util\Udap.Util.csproj", "{B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.CA", "examples\Udap.CA\Udap.CA.csproj", "{A634BD23-B31B-4260-922E-24FEC8B4CF22}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.CA.Tests", "_tests\Udap.CA.Tests\Udap.CA.Tests.csproj", "{9191CC9B-4DD0-4115-8FFD-A710659DBC27}"
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Udap.Client.System.Tests", "_tests\Udap.Client.System.Tests\Udap.Client.System.Tests.csproj", "{63DEA604-5D81-4C3A-AF43-0A296372EB20}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UdapMetadata.Tests", "_tests\UdapMetadata.Tests\UdapMetadata.Tests.csproj", "{872BDB24-9C83-4089-A5ED-086CAC6103B1}"
@@ -104,6 +100,49 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Server.Storage", "Udap
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Tefca.Model", "Udap.Tefca.Model\Udap.Tefca.Model.csproj", "{0DBA6694-00CA-49DA-96D8-A71697BAFCBB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Tefca.Server", "Udap.Tefca.Server\Udap.Tefca.Server.csproj", "{6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Udap.Ssraa.Server", "Udap.Ssraa.Server\Udap.Ssraa.Server.csproj", "{0E818FA7-3670-4E89-B4C5-656DD7E7FB73}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{A5033865-E5C6-4D6D-9101-2A3D2C2802BE}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "specifications", "specifications", "{BC60574D-16B6-45FA-8304-9B1C8C4B461D}"
+ ProjectSection(SolutionItems) = preProject
+ docs\specifications\QTF-2.1-Draft-12.04.25-clean1.pdf = docs\specifications\QTF-2.1-Draft-12.04.25-clean1.pdf
+ docs\specifications\SOP-Directory-Requirements_508-1.pdf = docs\specifications\SOP-Directory-Requirements_508-1.pdf
+ docs\specifications\SOP-Exchange-Purposes_CA-v2_v4-508.pdf = docs\specifications\SOP-Exchange-Purposes_CA-v2_v4-508.pdf
+ docs\specifications\SOP-Facilitated-FHIR-Implementation-2.0-Draft-508.pdf = docs\specifications\SOP-Facilitated-FHIR-Implementation-2.0-Draft-508.pdf
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CA", "CA", "{0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}"
+ ProjectSection(SolutionItems) = preProject
+ examples\CA\README.md = examples\CA\README.md
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Common", "examples\CA\Sigil.Common\Sigil.Common.csproj", "{3805EA18-B451-063D-B22C-8342113DBFBF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.UI", "examples\CA\Sigil.UI\Sigil.UI.csproj", "{0AB89BB3-8C37-5C1C-66DC-348052B379A9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil", "examples\CA\Sigil\Sigil.csproj", "{6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.AppHost", "examples\CA\Sigil.AppHost\Sigil.AppHost.csproj", "{860FEFA9-6FD6-5F81-A3ED-65B532691C8F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.ServiceDefaults", "examples\CA\Sigil.ServiceDefaults\Sigil.ServiceDefaults.csproj", "{239D5685-73B4-3B87-1C71-8B5F86A87BE5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Vault.Hosting", "examples\CA\Sigil.Vault.Hosting\Sigil.Vault.Hosting.csproj", "{B7B2B726-59D4-EE71-3853-DE2942846E49}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_tests", "_tests", "{02AA470A-D838-4674-8373-D81C848E5138}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Signing.Tests", "examples\CA\_tests\Sigil.Signing.Tests\Sigil.Signing.Tests.csproj", "{7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Vault", "examples\CA\Sigil.Vault\Sigil.Vault.csproj", "{754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Gcp", "examples\CA\Sigil.Gcp\Sigil.Gcp.csproj", "{927A9F26-8854-4AD1-15B3-4669C0842D88}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.FileSystem", "examples\CA\Sigil.FileSystem\Sigil.FileSystem.csproj", "{1AAB0311-6318-870E-2F18-9F71A52C7B31}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sigil.Certificate.Server", "examples\CA\Sigil.Certificate.Server\Sigil.Certificate.Server.csproj", "{EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -333,42 +372,6 @@ Global
{B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}.Release|x64.Build.0 = Release|Any CPU
{B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}.Release|x86.ActiveCfg = Release|Any CPU
{B00FC6ED-E967-4FD6-AAE9-ABCDDB328902}.Release|x86.Build.0 = Release|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x64.ActiveCfg = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x64.Build.0 = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x86.ActiveCfg = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Debug|x86.Build.0 = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x64.ActiveCfg = FullDebug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x64.Build.0 = FullDebug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x86.ActiveCfg = FullDebug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.FullDebug|x86.Build.0 = FullDebug|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|Any CPU.Build.0 = Release|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x64.ActiveCfg = Release|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x64.Build.0 = Release|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x86.ActiveCfg = Release|Any CPU
- {A634BD23-B31B-4260-922E-24FEC8B4CF22}.Release|x86.Build.0 = Release|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x64.ActiveCfg = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x64.Build.0 = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x86.ActiveCfg = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Debug|x86.Build.0 = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x64.ActiveCfg = FullDebug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x64.Build.0 = FullDebug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x86.ActiveCfg = FullDebug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.FullDebug|x86.Build.0 = FullDebug|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|Any CPU.Build.0 = Release|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x64.ActiveCfg = Release|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x64.Build.0 = Release|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x86.ActiveCfg = Release|Any CPU
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27}.Release|x86.Build.0 = Release|Any CPU
{63DEA604-5D81-4C3A-AF43-0A296372EB20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{63DEA604-5D81-4C3A-AF43-0A296372EB20}.Debug|Any CPU.Build.0 = Debug|Any CPU
{63DEA604-5D81-4C3A-AF43-0A296372EB20}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -765,6 +768,240 @@ Global
{0DBA6694-00CA-49DA-96D8-A71697BAFCBB}.Release|x64.Build.0 = Release|Any CPU
{0DBA6694-00CA-49DA-96D8-A71697BAFCBB}.Release|x86.ActiveCfg = Release|Any CPU
{0DBA6694-00CA-49DA-96D8-A71697BAFCBB}.Release|x86.Build.0 = Release|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x64.Build.0 = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Debug|x86.Build.0 = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x64.ActiveCfg = Release|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x64.Build.0 = Release|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x86.ActiveCfg = Release|Any CPU
+ {6F5246AE-2DBE-2E45-785D-8FAC7C344C7D}.Release|x86.Build.0 = Release|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x64.Build.0 = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Debug|x86.Build.0 = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x64.ActiveCfg = Release|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x64.Build.0 = Release|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x86.ActiveCfg = Release|Any CPU
+ {0E818FA7-3670-4E89-B4C5-656DD7E7FB73}.Release|x86.Build.0 = Release|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x64.Build.0 = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Debug|x86.Build.0 = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x64.ActiveCfg = Release|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x64.Build.0 = Release|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x86.ActiveCfg = Release|Any CPU
+ {3805EA18-B451-063D-B22C-8342113DBFBF}.Release|x86.Build.0 = Release|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x64.Build.0 = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Debug|x86.Build.0 = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x64.ActiveCfg = Release|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x64.Build.0 = Release|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x86.ActiveCfg = Release|Any CPU
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9}.Release|x86.Build.0 = Release|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x64.Build.0 = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Debug|x86.Build.0 = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x64.ActiveCfg = Release|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x64.Build.0 = Release|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x86.ActiveCfg = Release|Any CPU
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F}.Release|x86.Build.0 = Release|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x64.Build.0 = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Debug|x86.Build.0 = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x64.ActiveCfg = Release|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x64.Build.0 = Release|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x86.ActiveCfg = Release|Any CPU
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F}.Release|x86.Build.0 = Release|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x64.Build.0 = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Debug|x86.Build.0 = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x64.ActiveCfg = Release|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x64.Build.0 = Release|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x86.ActiveCfg = Release|Any CPU
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5}.Release|x86.Build.0 = Release|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x64.Build.0 = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Debug|x86.Build.0 = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x64.ActiveCfg = Release|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x64.Build.0 = Release|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x86.ActiveCfg = Release|Any CPU
+ {B7B2B726-59D4-EE71-3853-DE2942846E49}.Release|x86.Build.0 = Release|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x64.Build.0 = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Debug|x86.Build.0 = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x64.ActiveCfg = Release|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x64.Build.0 = Release|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x86.ActiveCfg = Release|Any CPU
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC}.Release|x86.Build.0 = Release|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x64.Build.0 = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Debug|x86.Build.0 = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x64.ActiveCfg = Release|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x64.Build.0 = Release|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x86.ActiveCfg = Release|Any CPU
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4}.Release|x86.Build.0 = Release|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x64.Build.0 = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Debug|x86.Build.0 = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|Any CPU.Build.0 = Release|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x64.ActiveCfg = Release|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x64.Build.0 = Release|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x86.ActiveCfg = Release|Any CPU
+ {927A9F26-8854-4AD1-15B3-4669C0842D88}.Release|x86.Build.0 = Release|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x64.Build.0 = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Debug|x86.Build.0 = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x64.ActiveCfg = Release|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x64.Build.0 = Release|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x86.ActiveCfg = Release|Any CPU
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31}.Release|x86.Build.0 = Release|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x64.Build.0 = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Debug|x86.Build.0 = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|Any CPU.Build.0 = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x64.ActiveCfg = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x64.Build.0 = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x86.ActiveCfg = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.FullDebug|x86.Build.0 = Debug|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x64.ActiveCfg = Release|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x64.Build.0 = Release|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x86.ActiveCfg = Release|Any CPU
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -777,8 +1014,6 @@ Global
{D617C732-6C2E-4AEF-8E1A-A4328EC32104} = {640C178B-FA29-49AD-8245-FA7EE934D522}
{0B925B7B-4E10-41CB-B6F9-708E134674CE} = {B73A108B-9163-4463-B755-A0F84344D088}
{2D52ACC2-6D22-4C31-A90C-505D04980EEF} = {640C178B-FA29-49AD-8245-FA7EE934D522}
- {A634BD23-B31B-4260-922E-24FEC8B4CF22} = {4B854644-1F54-4ECA-9290-A89B91A19FF9}
- {9191CC9B-4DD0-4115-8FFD-A710659DBC27} = {640C178B-FA29-49AD-8245-FA7EE934D522}
{63DEA604-5D81-4C3A-AF43-0A296372EB20} = {640C178B-FA29-49AD-8245-FA7EE934D522}
{872BDB24-9C83-4089-A5ED-086CAC6103B1} = {640C178B-FA29-49AD-8245-FA7EE934D522}
{A933AA5E-A9EE-4FED-8046-3663C4FF6C61} = {2D52ACC2-6D22-4C31-A90C-505D04980EEF}
@@ -794,6 +1029,21 @@ Global
{FDB5712F-2EB4-4A36-9DEF-955B3575024D} = {4B854644-1F54-4ECA-9290-A89B91A19FF9}
{147096AD-4426-42DD-AD6C-8EDD527F7C55} = {640C178B-FA29-49AD-8245-FA7EE934D522}
{B899C796-D7D0-4C02-AEAE-E2C0DD61802E} = {4B854644-1F54-4ECA-9290-A89B91A19FF9}
+ {A5033865-E5C6-4D6D-9101-2A3D2C2802BE} = {522A1681-215E-4C39-B1AC-1324F7CE516E}
+ {BC60574D-16B6-45FA-8304-9B1C8C4B461D} = {A5033865-E5C6-4D6D-9101-2A3D2C2802BE}
+ {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB} = {4B854644-1F54-4ECA-9290-A89B91A19FF9}
+ {3805EA18-B451-063D-B22C-8342113DBFBF} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {0AB89BB3-8C37-5C1C-66DC-348052B379A9} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {6C10F6D0-F021-A4C0-3E7E-E6D5E3C0C80F} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {860FEFA9-6FD6-5F81-A3ED-65B532691C8F} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {239D5685-73B4-3B87-1C71-8B5F86A87BE5} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {B7B2B726-59D4-EE71-3853-DE2942846E49} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {02AA470A-D838-4674-8373-D81C848E5138} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {7E8B7F8E-4FEE-A3D6-08AD-A4C646073BFC} = {02AA470A-D838-4674-8373-D81C848E5138}
+ {754CA443-8EAB-3D1C-F3D6-2E97D07AC2E4} = {4B854644-1F54-4ECA-9290-A89B91A19FF9}
+ {927A9F26-8854-4AD1-15B3-4669C0842D88} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {1AAB0311-6318-870E-2F18-9F71A52C7B31} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
+ {EADA42D2-A3C2-EE95-02C1-3D0B85E4E2D1} = {0CAF81E6-F0D7-4E13-B2FF-E8409AD819BB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4C22E2C0-B5D3-497F-BAD0-EB769D6D6CD1}
diff --git a/_tests/Directory.Packages.props b/_tests/Directory.Packages.props
index 134a95556..54c9ed1d6 100644
--- a/_tests/Directory.Packages.props
+++ b/_tests/Directory.Packages.props
@@ -16,26 +16,26 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -47,6 +47,6 @@
-
+
\ No newline at end of file
diff --git a/_tests/Udap.CA.Tests/Udap.CA.Tests.csproj b/_tests/Udap.CA.Tests/Udap.CA.Tests.csproj
deleted file mode 100644
index aa26cfe82..000000000
--- a/_tests/Udap.CA.Tests/Udap.CA.Tests.csproj
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
- net10.0
- enable
- enable
-
- false
-
-
-
- 0
-
-
-
- 0
-
-
-
-
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
-
-
-
-
diff --git a/_tests/Udap.CA.Tests/UnitTest1.cs b/_tests/Udap.CA.Tests/UnitTest1.cs
deleted file mode 100644
index f189964fb..000000000
--- a/_tests/Udap.CA.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,159 +0,0 @@
-using System.Formats.Asn1;
-using System.Security.Cryptography;
-using System.Security.Cryptography.X509Certificates;
-using Udap.CA.Services;
-using Udap.Util.Extensions;
-// ReSharper disable All
-
-
-namespace Udap.CA.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void GenerateRootCertTestWithDispose()
- {
- var subject = "CN=SureFhir-TestCA, OU=Root, O=Fhir Coding, L=Portland, S=Oregon, C=US";
-
- using var certificateUtilities = new CertificateUtilities();
- var rootCert = certificateUtilities.GenerateRootCA(subject);
-
- Assert.Equal(subject, rootCert.Subject);
- Assert.True(rootCert.HasPrivateKey);
- Assert.Equal(subject, rootCert.Issuer);
-
- certificateUtilities.Dispose();
-
- //
- // Just exercising dispose test behavior
- //
- Action action = () => rootCert.Subject.ToString();
- var ex = Assert.Throws(action);
- Assert.Equal("m_safeCertContext is an invalid handle.", ex.Message, StringComparer.OrdinalIgnoreCase);
- }
-
-
- public X509Certificate2 GenerateRootCertTest()
- {
- var subject = "CN=SureFhir-TestCA, OU=Root, O=Fhir Coding, L=Portland, S=Oregon, C=US";
-
- var certificateUtilities = new CertificateUtilities();
- var rootCert = certificateUtilities.GenerateRootCA(subject);
-
- Assert.Equal(subject, rootCert.Subject);
- Assert.True(rootCert.HasPrivateKey);
- Assert.Equal(subject, rootCert.Issuer);
-
- return rootCert;
- }
-
-
- public X509Certificate2 GenerateIntermediateCertTest()
- {
- var rootCertificate = GenerateRootCertTest();
-
- var subject = "CN=SureFhir-TestAnchor, OU=Anchor, O=Fhir Coding, L=Portland, S=Oregon, C=US";
- var subjectAltName = new Uri("http://fhirwalker.com");
- var crl = new Uri("http://crl.fhircerts.net/crl/SureFhir-TestAnchor.crl");
- var certificateAuthIssuerUri = new Uri("http://crl.fhircerts.net/certs/intermediates/SureFhir-TestAnchor.cer");
-
- var certificateUtilities = new CertificateUtilities();
-
- var intermediateCertificate = certificateUtilities.GenerateIntermediate(
- subject,
- subjectAltName,
- crl,
- certificateAuthIssuerUri,
- rootCertificate);
-
- Assert.Equal(subject, intermediateCertificate.Subject);
- Assert.True(intermediateCertificate.HasPrivateKey);
- Assert.Equal(rootCertificate.Subject, intermediateCertificate.Issuer);
-
- return intermediateCertificate;
- }
-
- [Fact]
- public void GenerateIssuedCertTest()
- {
- var intermediateCertificate = GenerateIntermediateCertTest();
-
- var subject = "CN=test.fhirlabs.net, OU=Do not use for PHI, O=Fhir Coding, L=Portland, S=Oregon, C=US";
- var subjectAltName = new Uri("https://test.fhirlabs.net/fhir/r4");
- var crl = new Uri("http://crl.fhircerts.net/crl/surefhirlabs.crl");
- var certificateAuthIssuerUri = new Uri("http://crl.fhircerts.net/certs/intermediates/SureFhirLabs_Intermediate.cer");
-
- var certificateUtilities = new CertificateUtilities();
-
- var issuedCertificate = certificateUtilities.GenerateEndCert(
- subject,
- subjectAltName,
- crl,
- certificateAuthIssuerUri,
- intermediateCertificate);
-
-
- Assert.Equal(subject, issuedCertificate.Subject);
- //
- // It might be cool to try using the Microsoft AsnReader instead of BouncyCastle.
- // Good resources here:
- // https://stackoverflow.com/questions/70217305/how-to-use-system-formats-asn1-asnreader
- // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Formats.Asn1/tests/Reader/ReadSequence.cs
- // https://github.com/dotnet/designs/blob/ec974c0b7d87d984f498651af2a3e157ba579f01/accepted/2020/asnreader/asnreader.md
- //
- issuedCertificate.GetExtensionValue("1.3.6.1.5.5.7.1.1");
-
- Assert.True(issuedCertificate.HasPrivateKey);
- Assert.Equal(intermediateCertificate.Subject, issuedCertificate.Issuer);
-
- var aiaExtensions =
- issuedCertificate.Extensions["1.3.6.1.5.5.7.1.1"] as X509AuthorityInformationAccessExtension;
- Assert.NotNull(aiaExtensions);
- Assert.Equal(certificateAuthIssuerUri.AbsoluteUri, aiaExtensions!.EnumerateCAIssuersUris().Single());
-
- //
- // No good because it just gets the first one
- //
- var joe = issuedCertificate.GetNameInfo(X509NameType.UrlName, false);
-
-
- var subjectAltNameExtension =
- issuedCertificate.Extensions["2.5.29.17"] as X509SubjectAlternativeNameExtension;
-
- //
- // This is but wont work for UDAP because the string sent to MatchesHostname must be a domain name
- // test.fhirlabs.net would work but test.fhirlabs.net/fhir/r4 would not
- //
- // Actually MatchesHostname is not build for checking a Uri. Internally is gets a X509SubjectAlternativeNameExtension
- // like on the previous code line and has access to two enumerates methods; EnumerateDnsNames() and EnumerateIPAddresses().
- // There is no EnumerateUris(). So we should write one. TODO
- //
- // So the next line would work great as an SSL validation but not for UDAP
- // issuedCertificate.MatchesHostname("test.fhirlabs.net").Should().BeTrue();
- //
- // Below is some code that uses the AsnReader to find the URI
-
-
- // char[] clientPem = PemEncoding.Write("CERTIFICATE", issuedCertificate.RawData);
- // File.WriteAllBytes($"__Test__.cer", clientPem.Select(c => (byte)c).ToArray());
-
-
-
- //TODO: this code needs to be put into a library so we can used it to assert subAltName the same as iss and url etc...
- ReadOnlyMemory encoded = subjectAltNameExtension.RawData;
- AsnReader reader = new AsnReader(subjectAltNameExtension.RawData, AsnEncodingRules.DER);
- Assert.True(reader.HasData);
- AsnReader sanExtensionValue = reader.ReadSequence();
- Assert.False(reader.HasData);
- Asn1Tag uriName = new Asn1Tag(TagClass.ContextSpecific, 6);
-
- //
- // extra subAltName for what if scenario for now. Future work.
- //
- // sanExtensionValue.ReadCharacterString(UniversalTagNumber.IA5String, uriName).Should().Be("http://localhost/");
-
- Assert.Equal(subjectAltName.AbsoluteUri, sanExtensionValue.ReadCharacterString(UniversalTagNumber.IA5String, uriName));
-
- }
-
-}
\ No newline at end of file
diff --git a/_tests/Udap.CA.Tests/Usings.cs b/_tests/Udap.CA.Tests/Usings.cs
deleted file mode 100644
index 8c927eb74..000000000
--- a/_tests/Udap.CA.Tests/Usings.cs
+++ /dev/null
@@ -1 +0,0 @@
-global using Xunit;
\ No newline at end of file
diff --git a/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs b/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs
index 6e76eb32a..6311cd640 100644
--- a/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs
+++ b/_tests/Udap.Common.Tests/Model/Access/AccessTokenTests.cs
@@ -150,4 +150,114 @@ public void TestHl7B2BExtensionSerialization()
b2BHl7 = JsonSerializer.Deserialize(b2BHl7.SerializeToJson());
Assert.Equal(0, b2BHl7!.PurposeOfUse!.Count);
}
+
+ [Fact]
+ public void ClientCredentials_WithCertificateChain_IncludesMultipleX5cEntries()
+ {
+ var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx");
+ var clientCert = new X509Certificate2(certPath, "udap-test");
+
+ var intermediatePath = Path.Combine(AppContext.BaseDirectory, "CertStore/intermediates", "SureFhirLabs_Intermediate.cer");
+ var intermediateCert = new X509Certificate2(intermediatePath);
+
+ var certificates = new List { clientCert, intermediateCert };
+
+ var clientRequest = AccessTokenRequestForClientCredentialsBuilder.Create(
+ "test-client-id",
+ "https://server/connect/token",
+ certificates)
+ .WithScope("system/Patient.rs")
+ .Build("RS384");
+
+ Assert.NotNull(clientRequest.ClientAssertion.Value);
+
+ var handler = new JwtSecurityTokenHandler();
+ var jwtToken = handler.ReadJwtToken(clientRequest.ClientAssertion.Value);
+
+ // Verify x5c header contains two certificates
+ Assert.True(jwtToken.Header.ContainsKey("x5c"));
+ var x5cArray = jwtToken.Header["x5c"] as List;
+ Assert.NotNull(x5cArray);
+ Assert.Equal(2, x5cArray.Count);
+ }
+
+ [Fact]
+ public void AuthorizationCode_WithCertificateChain_IncludesMultipleX5cEntries()
+ {
+ var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx");
+ var clientCert = new X509Certificate2(certPath, "udap-test");
+
+ var intermediatePath = Path.Combine(AppContext.BaseDirectory, "CertStore/intermediates", "SureFhirLabs_Intermediate.cer");
+ var intermediateCert = new X509Certificate2(intermediatePath);
+
+ var certificates = new List { clientCert, intermediateCert };
+
+ var tokenRequest = AccessTokenRequestForAuthorizationCodeBuilder.Create(
+ "test-client-id",
+ "https://server/connect/token",
+ certificates,
+ "https://client/callback",
+ "test-auth-code")
+ .Build("RS384");
+
+ Assert.NotNull(tokenRequest.ClientAssertion.Value);
+
+ var handler = new JwtSecurityTokenHandler();
+ var jwtToken = handler.ReadJwtToken(tokenRequest.ClientAssertion.Value);
+
+ // Verify x5c header contains two certificates
+ Assert.True(jwtToken.Header.ContainsKey("x5c"));
+ var x5cArray = jwtToken.Header["x5c"] as List;
+ Assert.NotNull(x5cArray);
+ Assert.Equal(2, x5cArray.Count);
+ }
+
+ [Fact]
+ public void ClientCredentials_SingleCert_StillWorks()
+ {
+ var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx");
+ var clientCert = new X509Certificate2(certPath, "udap-test");
+
+ var clientRequest = AccessTokenRequestForClientCredentialsBuilder.Create(
+ "test-client-id",
+ "https://server/connect/token",
+ clientCert)
+ .WithScope("system/Patient.rs")
+ .Build("RS384");
+
+ Assert.NotNull(clientRequest.ClientAssertion.Value);
+
+ var handler = new JwtSecurityTokenHandler();
+ var jwtToken = handler.ReadJwtToken(clientRequest.ClientAssertion.Value);
+
+ Assert.True(jwtToken.Header.ContainsKey("x5c"));
+ var x5cArray = jwtToken.Header["x5c"] as List;
+ Assert.NotNull(x5cArray);
+ Assert.Single(x5cArray);
+ }
+
+ [Fact]
+ public void AuthorizationCode_SingleCert_StillWorks()
+ {
+ var certPath = Path.Combine(AppContext.BaseDirectory, "CertStore/issued", "fhirlabs.net.client.pfx");
+ var clientCert = new X509Certificate2(certPath, "udap-test");
+
+ var tokenRequest = AccessTokenRequestForAuthorizationCodeBuilder.Create(
+ "test-client-id",
+ "https://server/connect/token",
+ clientCert,
+ "https://client/callback",
+ "test-auth-code")
+ .Build("RS384");
+
+ Assert.NotNull(tokenRequest.ClientAssertion.Value);
+
+ var handler = new JwtSecurityTokenHandler();
+ var jwtToken = handler.ReadJwtToken(tokenRequest.ClientAssertion.Value);
+
+ Assert.True(jwtToken.Header.ContainsKey("x5c"));
+ var x5cArray = jwtToken.Header["x5c"] as List;
+ Assert.NotNull(x5cArray);
+ Assert.Single(x5cArray);
+ }
}
diff --git a/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs b/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs
index 545c49b3b..c5801e63a 100644
--- a/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs
+++ b/_tests/Udap.Common.Tests/Model/Registration/CertificationsDocumentTest.cs
@@ -11,6 +11,7 @@
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Duende.IdentityModel;
+using Microsoft.IdentityModel.Tokens;
using Udap.Model.Registration;
using Xunit.Abstractions;
@@ -528,11 +529,94 @@ public void ClampedExpiration_CertificateSoonButNotTooSoon_IsClampedNotThrown()
// Allow tiny drift
Assert.True((expected - expDt).Duration() < TimeSpan.FromSeconds(2));
}
+
+ [Fact]
+ public void AdditionalClaims_SurviveSerializationRoundTrip()
+ {
+ var document = new UdapCertificationAndEndorsementDocument("TEFCA Basic App Certification")
+ {
+ Issuer = "urn:oid:2.999#T-TRTMNT",
+ Subject = "urn:oid:2.999#T-TRTMNT",
+ Scope = "user/*.read",
+ AdditionalClaims = new Dictionary
+ {
+ ["exchange_purposes"] = JsonSerializer.SerializeToElement(
+ new[] { "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS" }),
+ ["home_community_id"] = JsonSerializer.SerializeToElement("urn:oid:2.999")
+ }
+ };
+
+ var json = document.SerializeToJson();
+ _testOutputHelper.WriteLine(json);
+
+ // Verify additional claims are in the serialized JSON
+ var parsed = JsonDocument.Parse(json);
+ Assert.True(parsed.RootElement.TryGetProperty("exchange_purposes", out var ep));
+ Assert.Equal(JsonValueKind.Array, ep.ValueKind);
+ Assert.Equal("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS", ep[0].GetString());
+
+ Assert.True(parsed.RootElement.TryGetProperty("home_community_id", out var hc));
+ Assert.Equal("urn:oid:2.999", hc.GetString());
+
+ // Deserialize back and verify additional claims survive
+ var deserialized = JsonSerializer.Deserialize(json);
+ Assert.NotNull(deserialized);
+ Assert.Equal("TEFCA Basic App Certification", deserialized!.CertificationName);
+ Assert.Equal("user/*.read", deserialized.Scope);
+ Assert.NotNull(deserialized.AdditionalClaims);
+ Assert.True(deserialized.AdditionalClaims!.ContainsKey("exchange_purposes"));
+ Assert.True(deserialized.AdditionalClaims.ContainsKey("home_community_id"));
+
+ // Re-serialize and verify claims are still present
+ var reJson = deserialized.SerializeToJson();
+ var reParsed = JsonDocument.Parse(reJson);
+ Assert.True(reParsed.RootElement.TryGetProperty("exchange_purposes", out _));
+ Assert.True(reParsed.RootElement.TryGetProperty("home_community_id", out _));
+ }
+
+ [Fact]
+ public void AdditionalClaims_IncludedInSignedSoftwareStatement()
+ {
+ var certificationCert =
+ new X509Certificate2(Path.Combine("CertStore/issued", "FhirLabsAdminCertification.pfx"), "udap-test");
+
+ var signedJwt = UdapCertificationsAndEndorsementBuilder
+ .Create("TEFCA Basic App Certification", certificationCert)
+ .WithClampedExpiration(TimeSpan.FromMinutes(5))
+ .WithCertificationDescription("TEFCA Basic App Certification")
+ .WithCertificationUris(new List
+ { "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification" })
+ .WithScope("user/*.read")
+ .WithTokenEndpointAuthMethod("private_key_jwt")
+ .WithAdditionalClaims(new Dictionary
+ {
+ ["exchange_purposes"] = JsonSerializer.SerializeToElement(
+ new[] { "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS" }),
+ ["home_community_id"] = JsonSerializer.SerializeToElement(
+ "2.16.840.1.113883.3.2054.2.4")
+ })
+ .BuildSoftwareStatement();
+
+ Assert.NotNull(signedJwt);
+
+ // Decode payload and verify additional claims are present
+ var parts = signedJwt.Split('.');
+ Assert.Equal(3, parts.Length);
+
+ var payloadJson = Base64UrlEncoder.Decode(parts[1]);
+ _testOutputHelper.WriteLine(payloadJson);
+
+ var payload = JsonDocument.Parse(payloadJson);
+ Assert.True(payload.RootElement.TryGetProperty("exchange_purposes", out var ep));
+ Assert.Equal("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS", ep[0].GetString());
+ Assert.True(payload.RootElement.TryGetProperty("home_community_id", out var hc));
+ Assert.Equal("2.16.840.1.113883.3.2054.2.4", hc.GetString());
+ }
}
//
// Register with only client_credentials C&E and then fail when a toke is requested for authorization_code.
- // Even if the standard registration contained authorization_code. This
+ // Even if the standard registration contained authorization_code. This
//
//
diff --git a/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs b/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs
index ffb69ab6f..2efa02d36 100644
--- a/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs
+++ b/_tests/Udap.Common.Tests/Model/Registration/UdapDynamicClientRegistrationDocumentTest.cs
@@ -416,7 +416,6 @@ public void TestTefcaIasExtensionSerialization()
{
UserInformation = relatedPersonElement,
PatientInformation = patientElement,
- IalVetted = identityTokenElement,
IdToken = identityTokenElement
};
@@ -430,14 +429,11 @@ public void TestTefcaIasExtensionSerialization()
// _testOutputHelper.WriteLine(serializeDocument);
- Assert.Contains("T-IAS", serializeDocument);
Assert.Contains("https://udaped.fhirlabs.net/Policy/Consent/99", serializeDocument);
Assert.Contains("https://fhirlabs.net/fhir/r4/Consent/99", serializeDocument);
tefcaIas = JsonSerializer.Deserialize(serializeDocument);
- Assert.Equal("T-IAS", tefcaIas!.PurposeOfUse);
-
tefcaIas.ConsentPolicy!.Remove("https://udaped.fhirlabs.net/Policy/Consent/99");
Assert.Equal(0, tefcaIas.ConsentPolicy.Count);
@@ -445,10 +441,6 @@ public void TestTefcaIasExtensionSerialization()
tefcaIas = JsonSerializer.Deserialize(tefcaIas.SerializeToJson());
Assert.Equal(0, tefcaIas!.ConsentPolicy!.Count);
- Assert.Equal(identityTokenElement.GetRawText()
- .Replace("\n", "").Replace("\r", "").Replace(": ", ":").Replace(", ", ","),
- tefcaIas.IalVetted?.GetRawText(), StringComparer.OrdinalIgnoreCase);
-
Assert.Equal(identityTokenElement.GetRawText()
.Replace("\n", "").Replace("\r", "").Replace(": ", ":").Replace(", ", ","),
tefcaIas.IdToken?.GetRawText(), StringComparer.OrdinalIgnoreCase);
@@ -753,14 +745,13 @@ public void TefcaIasExtensionValidationTest()
{
var hl7B2BUser = new TEFCAIASAuthorizationExtension()
{
- Version = null!,
- PurposeOfUse = "Bad"
+ Version = null!
};
var notes = hl7B2BUser.Validate();
Assert.NotNull(notes);
- Assert.Equal(4, notes.Count);
- Assert.Equal(new List { "Missing required version", "Missing required user_information", "Missing required patient_information", "purpose_of_use must be T-IAS" }, notes);
+ Assert.Equal(3, notes.Count);
+ Assert.Equal(new List { "Missing required version", "Missing required user_information", "Missing required patient_information" }, notes);
}
[Fact]
diff --git a/_tests/Udap.Common.Tests/Model/Tefca/TEFCAAuthorizationErrorExtensionTests.cs b/_tests/Udap.Common.Tests/Model/Tefca/TEFCAAuthorizationErrorExtensionTests.cs
new file mode 100644
index 000000000..2403dfffb
--- /dev/null
+++ b/_tests/Udap.Common.Tests/Model/Tefca/TEFCAAuthorizationErrorExtensionTests.cs
@@ -0,0 +1,93 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using System.Text.Json;
+using Udap.Tefca.Model;
+using Xunit;
+
+namespace Udap.Common.Tests.Model.Tefca;
+
+///
+/// Tests for .
+///
+///
+/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 B2B #3, Table 1
+///
+public class TEFCAAuthorizationErrorExtensionTests
+{
+ [Fact]
+ public void Serialize_ConsentRequired_Only()
+ {
+ var error = new TEFCAAuthorizationErrorExtension
+ {
+ ConsentRequired = ["urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1"]
+ };
+
+ var json = JsonSerializer.Serialize(error);
+ using var parsed = JsonDocument.Parse(json);
+ var root = parsed.RootElement;
+
+ Assert.True(root.TryGetProperty("consent_required", out var cr));
+ Assert.Equal(JsonValueKind.Array, cr.ValueKind);
+ Assert.Single(cr.EnumerateArray());
+ Assert.Equal("urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1", cr[0].GetString());
+
+ Assert.True(root.TryGetProperty("consent_form", out var cf));
+ Assert.Equal(JsonValueKind.Null, cf.ValueKind);
+ }
+
+ [Fact]
+ public void Serialize_ConsentRequired_With_ConsentForm()
+ {
+ var error = new TEFCAAuthorizationErrorExtension
+ {
+ ConsentRequired = [
+ "urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1",
+ "urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.2"
+ ],
+ ConsentForm = "https://tefca.example.com/consent/form.pdf"
+ };
+
+ var json = JsonSerializer.Serialize(error);
+ using var parsed = JsonDocument.Parse(json);
+ var root = parsed.RootElement;
+
+ Assert.True(root.TryGetProperty("consent_required", out var cr));
+ Assert.Equal(2, cr.GetArrayLength());
+
+ Assert.True(root.TryGetProperty("consent_form", out var cf));
+ Assert.Equal("https://tefca.example.com/consent/form.pdf", cf.GetString());
+ }
+
+ [Fact]
+ public void Roundtrip_Deserialization()
+ {
+ var original = new TEFCAAuthorizationErrorExtension
+ {
+ ConsentRequired = ["urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1"],
+ ConsentForm = "https://tefca.example.com/consent/form.pdf"
+ };
+
+ var json = JsonSerializer.Serialize(original);
+ var deserialized = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(deserialized);
+ Assert.Single(deserialized.ConsentRequired);
+ Assert.Contains("urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1", deserialized.ConsentRequired);
+ Assert.Equal("https://tefca.example.com/consent/form.pdf", deserialized.ConsentForm);
+ }
+
+ [Fact]
+ public void Default_ConsentRequired_Is_Empty()
+ {
+ var error = new TEFCAAuthorizationErrorExtension();
+ Assert.Empty(error.ConsentRequired);
+ Assert.Null(error.ConsentForm);
+ }
+}
diff --git a/_tests/Udap.Common.Tests/Model/Tefca/TefcaCertificationDocumentTests.cs b/_tests/Udap.Common.Tests/Model/Tefca/TefcaCertificationDocumentTests.cs
new file mode 100644
index 000000000..14f8000fd
--- /dev/null
+++ b/_tests/Udap.Common.Tests/Model/Tefca/TefcaCertificationDocumentTests.cs
@@ -0,0 +1,116 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using System.Text.Json;
+using Udap.Tefca.Model;
+using Xunit;
+
+namespace Udap.Common.Tests.Model.Tefca;
+
+///
+/// Tests for .
+///
+///
+/// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 Registration #6
+///
+public class TefcaCertificationDocumentTests
+{
+ [Fact]
+ public void Constructor_Sets_CertificationName_And_Uris()
+ {
+ var doc = new TefcaCertificationDocument();
+
+ Assert.Equal(TefcaConstants.Certification.BasicAppCertificationName, doc.CertificationName);
+ Assert.Single(doc.CertificationUris!);
+ Assert.Contains(TefcaConstants.Certification.BasicAppCertificationUri, doc.CertificationUris!);
+ }
+
+ [Fact]
+ public void Serialize_Includes_ExchangePurposes()
+ {
+ var doc = new TefcaCertificationDocument
+ {
+ ExchangePurposes = [TefcaConstants.ExchangePurposeCodes.Treatment]
+ };
+
+ var json = doc.SerializeToJson();
+ using var parsed = JsonDocument.Parse(json);
+ var root = parsed.RootElement;
+
+ Assert.True(root.TryGetProperty("exchange_purposes", out var xp));
+ Assert.Equal(JsonValueKind.Array, xp.ValueKind);
+ Assert.Single(xp.EnumerateArray());
+ Assert.Equal("T-TREAT", xp[0].GetString());
+ }
+
+ [Fact]
+ public void Serialize_Includes_HomeCommunityId()
+ {
+ var doc = new TefcaCertificationDocument
+ {
+ HomeCommunityId = "urn:oid:1.2.3.4.5"
+ };
+
+ var json = doc.SerializeToJson();
+ using var parsed = JsonDocument.Parse(json);
+ var root = parsed.RootElement;
+
+ Assert.True(root.TryGetProperty("home_community_id", out var hcid));
+ Assert.Equal("urn:oid:1.2.3.4.5", hcid.GetString());
+ }
+
+ [Fact]
+ public void Serialize_Includes_Base_And_Tefca_Claims()
+ {
+ var doc = new TefcaCertificationDocument
+ {
+ Issuer = "https://rce.example.com",
+ Subject = "https://app.example.com",
+ ExchangePurposes = [TefcaConstants.ExchangePurposeCodes.IndividualAccessServices],
+ HomeCommunityId = "urn:oid:1.2.3.4.5"
+ };
+
+ var json = doc.SerializeToJson();
+ using var parsed = JsonDocument.Parse(json);
+ var root = parsed.RootElement;
+
+ // Base claims
+ Assert.True(root.TryGetProperty("iss", out var iss));
+ Assert.Equal("https://rce.example.com", iss.GetString());
+ Assert.True(root.TryGetProperty("certification_name", out var cn));
+ Assert.Equal("TEFCA Basic App Certification", cn.GetString());
+ Assert.True(root.TryGetProperty("certification_uris", out _));
+
+ // TEFCA claims
+ Assert.True(root.TryGetProperty("exchange_purposes", out var xp));
+ Assert.Equal("T-IAS", xp[0].GetString());
+ Assert.True(root.TryGetProperty("home_community_id", out _));
+ }
+
+ [Fact]
+ public void Roundtrip_Deserialization()
+ {
+ var doc = new TefcaCertificationDocument
+ {
+ Issuer = "https://rce.example.com",
+ Subject = "https://app.example.com",
+ ExchangePurposes = [TefcaConstants.ExchangePurposeCodes.TefcaRequiredTreatment],
+ HomeCommunityId = "urn:oid:9.8.7.6"
+ };
+
+ var json = doc.SerializeToJson();
+ var deserialized = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("TEFCA Basic App Certification", deserialized.CertificationName);
+ Assert.Single(deserialized.ExchangePurposes!);
+ Assert.Contains("T-TRTMNT", deserialized.ExchangePurposes!);
+ Assert.Equal("urn:oid:9.8.7.6", deserialized.HomeCommunityId);
+ }
+}
diff --git a/_tests/Udap.PKI.Generator/BuildTestCerts.cs b/_tests/Udap.PKI.Generator/BuildTestCerts.cs
index 63a3c0f8a..2132fd9e5 100644
--- a/_tests/Udap.PKI.Generator/BuildTestCerts.cs
+++ b/_tests/Udap.PKI.Generator/BuildTestCerts.cs
@@ -105,7 +105,7 @@ public BuildTestCerts(ITestOutputHelper testOutputHelper)
/// default community uri = udap://fhirlabs.net
///
///
- [Fact]
+ [Fact]//(Skip = "Enabled on desktop when needed.")]
public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by method name. Notice ITestCaseOrderer
{
Console.WriteLine("*************************************");
@@ -211,6 +211,31 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
SureFhirlabsUdapIssued.EnsureDirectoryExists();
+ BuildEndCertsForDefaultCommunity(caCert, intermediateCertWithoutKey, intermediateCertWithKey, intermediateRSAKey);
+ }
+ }
+
+ ///
+ /// Regenerate all end-entity certificates (UDAP client, ECDSA, SSL, CRLs) for the default community
+ /// using the existing CA and intermediate from disk. Does NOT rebuild the PKI.
+ ///
+ [Fact(Skip = "Enabled on desktop when needed.")]
+ public void MakeEndCertsForDefaultCommunity()
+ {
+ using var caCert = new X509Certificate2($"{SureFhirLabsCertStore}/SureFhirLabs_CA.pfx", "udap-test", X509KeyStorageFlags.Exportable);
+ using var subCA = new X509Certificate2($"{SureFhirlabsUdapIntermediates}/SureFhirLabs_Intermediate.pfx", "udap-test", X509KeyStorageFlags.Exportable);
+
+ SureFhirlabsUdapIssued.EnsureDirectoryExists();
+
+ BuildEndCertsForDefaultCommunity(caCert, subCA, subCA, subCA.GetRSAPrivateKey()!);
+ }
+
+ private void BuildEndCertsForDefaultCommunity(
+ X509Certificate2 caCert,
+ X509Certificate2 intermediateCertWithoutKey,
+ X509Certificate2 intermediateCertWithKey,
+ RSA intermediateRSAKey)
+ {
#region weatherapi.lab Client (Issued) Certificates
BuildUdapClientCertificate(
@@ -291,66 +316,7 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
SureFhirLabsIntermediatePublicCertHosted
);
#endregion
-
- #region weatherapi.lab SSL
-
- // using RSA rsaWeatherApiSsl = RSA.Create(2048);
- // var sslReq = new CertificateRequest(
- // "CN=weatherapi.lab, OU=SSL, O=Fhir Coding, L=Portland, S=Oregon, C=US",
- // rsaWeatherApiSsl,
- // HashAlgorithmName.SHA256,
- // RSASignaturePadding.Pkcs1);
- //
- // sslReq.CertificateExtensions.Add(
- // new X509BasicConstraintsExtension(false, false, 0, true));
- //
- // sslReq.CertificateExtensions.Add(
- // new X509KeyUsageExtension(
- // X509KeyUsageFlags.DigitalSignature,
- // true));
- //
- // sslReq.CertificateExtensions.Add(
- // new X509SubjectKeyIdentifierExtension(sslReq.PublicKey, false));
- //
- // AddAuthorityKeyIdentifier(intermediateCertWithoutKey, sslReq, _testOutputHelper);
- // sslReq.CertificateExtensions.Add(MakeCdp(SureFhirLabsIntermediateCrl));
- //
- // subAltNameBuilder = new SubjectAlternativeNameBuilder();
- // subAltNameBuilder.AddDnsName("weatherapi.lab");
- // x509Extension = subAltNameBuilder.Build();
- // sslReq.CertificateExtensions.Add(x509Extension);
- //
- // sslReq.CertificateExtensions.Add(
- // new X509EnhancedKeyUsageExtension(
- // new OidCollection {
- // new Oid("1.3.6.1.5.5.7.3.2"), // TLS Client auth
- // new Oid("1.3.6.1.5.5.7.3.1"), // TLS Server auth
- // },
- // true));
- //
- // using (var clientCert = sslReq.Create(
- // intermediateCertWithKey,
- // DateTimeOffset.UtcNow.AddDays(-1),
- // DateTimeOffset.UtcNow.AddYears(2),
- // new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))))
- // {
- // // Do something with these certs, like export them to PFX,
- // // or add them to an X509Store, or whatever.
- // var sslCert = clientCert.CopyWithPrivateKey(rsaWeatherApiSsl);
- //
- // SureFhirLabsSslWeatherApi.EnsureDirectoryExists();
- // var clientBytes = sslCert.Export(X509ContentType.Pkcs12, "udap-test");
- //
- // Console.WriteLine("*************************************");
- // Console.WriteLine($"{SureFhirLabsSslWeatherApi}/weatherapi.lab.pfx");
- // Console.WriteLine("*************************************");
- //
- // File.WriteAllBytes($"{SureFhirLabsSslWeatherApi}/weatherapi.lab.pfx", clientBytes);
- // char[] certificatePem = PemEncoding.Write("CERTIFICATE", clientCert.RawData);
- // File.WriteAllBytes($"{SureFhirLabsSslWeatherApi}/weatherapi.lab.cer", certificatePem.Select(c => (byte)c).ToArray());
- // }
- #endregion
-
+
#region fhirlabs.net SSL
using RSA rsaFhirLabsSsl = RSA.Create(2048);
@@ -375,9 +341,9 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
AddAuthorityKeyIdentifier(intermediateCertWithoutKey, sureFhirSSLReq, _testOutputHelper);
sureFhirSSLReq.CertificateExtensions.Add(MakeCdp(SureFhirLabsIntermediateCrl));
- subAltNameBuilder = new SubjectAlternativeNameBuilder();
+ var subAltNameBuilder = new SubjectAlternativeNameBuilder();
subAltNameBuilder.AddDnsName("fhirlabs.net");
- x509Extension = subAltNameBuilder.Build();
+ var x509Extension = subAltNameBuilder.Build();
sureFhirSSLReq.CertificateExtensions.Add(x509Extension);
sureFhirSSLReq.CertificateExtensions.Add(
@@ -408,7 +374,7 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
#endregion
- #region securedcontrols.net SSL :: Identity Provider
+ #region securedcontrols.net SSL :: Identity Provider
using RSA rsaSecuredControls = RSA.Create(2048);
@@ -556,7 +522,7 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
new X509SubjectKeyIdentifierExtension(hostDockerInternal.PublicKey, false));
AddAuthorityKeyIdentifier(caCert, hostDockerInternal, _testOutputHelper);
- // hostDockerInternal.CertificateExtensions.Add(MakeCdp(SureFhirLabsRootCrl));
+ // hostDockerInternal.CertificateExtensions.Add(MakeCdp(SureFhirLabsRootCrl));
subAltNameBuilder = new SubjectAlternativeNameBuilder();
subAltNameBuilder.AddDnsName("host.docker.internal");
@@ -590,7 +556,6 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
}
#endregion
- }
//Distribute
@@ -602,8 +567,9 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
File.Copy($"{SureFhirlabsUdapIssued}/fhirlabs.net.client.pfx",
$"{BaseDir}/../../examples/FhirLabsApi/CertStore/issued/fhirlabs.net.client.pfx",
true);
-
-
+
+
+
//
// Udap.Common.Tests
//
@@ -625,12 +591,12 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
- // Copy CA to FhirLabsApi so it can be added to the Docker Container trust store.
+ // Copy CA to FhirLabsApi so it can be added to the Docker Container trust store.
File.Copy($"{SureFhirLabsCertStore}/SureFhirLabs_CA.cer",
$"{BaseDir}/../../examples/FhirLabsApi/SureFhirLabs_CA.cer",
true);
- // Copy CA to Udap.Auth.Server so it can be added to the Docker Container trust store.
+ // Copy CA to Udap.Auth.Server so it can be added to the Docker Container trust store.
File.Copy($"{SureFhirLabsCertStore}/SureFhirLabs_CA.cer",
$"{BaseDir}/../../examples/Udap.Auth.Server/SureFhirLabs_CA.cer",
true);
@@ -646,10 +612,6 @@ public void MakeCaWithIntermediateUdapAndSSLForDefaultCommunity() // ordered by
$"{BaseDir}/../../examples/FhirLabsApi/host.docker.internal.pfx",
true);
- File.Copy($"{SureFhirLabsSslIdentityServer}/host.docker.internal.pfx",
- $"{BaseDir}/../../examples/Udap.CA/host.docker.internal.pfx",
- true);
-
File.Copy($"{SureFhirLabsSslIdentityServer}/host.docker.internal.pfx",
$"{BaseDir}/../../examples/Udap.Auth.Server.Admin/host.docker.internal.pfx",
true);
diff --git a/_tests/Udap.PKI.Generator/TefcaBuild.cs b/_tests/Udap.PKI.Generator/TefcaBuild.cs
new file mode 100644
index 000000000..6b0227ec8
--- /dev/null
+++ b/_tests/Udap.PKI.Generator/TefcaBuild.cs
@@ -0,0 +1,405 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto.Operators;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.X509;
+using Org.BouncyCastle.X509.Extension;
+using Udap.Support.Tests.Extensions;
+using Xunit.Abstractions;
+using X509Extensions = Org.BouncyCastle.Asn1.X509.X509Extensions;
+// ReSharper disable All
+#pragma warning disable xUnit1004
+
+namespace Udap.PKI.Generator;
+
+///
+/// Generates a self-contained TEFCA test PKI hierarchy and client certificates.
+///
+/// The local test hierarchy simulates an RCE-issued trust chain:
+/// TEFCA-Test-CA → TEFCA-Test-Intermediate → Client Certificates
+///
+/// Client certificates are issued with SAN URIs containing exchange purposes
+/// in the path, per SOP: Facilitated FHIR Implementation v2.0 Section 6.11 Registration #5a.
+///
+/// Two variants are generated:
+/// - TEFCA_Community: AIA/CRL point to crl.fhircerts.net (for deployment)
+/// - TEFCA_Community_Desk: AIA/CRL point to host.docker.internal:5033 (for local dev)
+///
+/// SOP v2.0 — Section 6.11
+///
+public class TefcaBuild : CertificateBase
+{
+ private readonly ITestOutputHelper _testOutputHelper;
+
+ public TefcaBuild(ITestOutputHelper testOutputHelper)
+ {
+ _testOutputHelper = testOutputHelper;
+ }
+
+ ///
+ /// Generates TEFCA PKI for deployment — AIA/CRL URLs point to crl.fhircerts.net.
+ /// Output directory: certstores/TEFCA_Community
+ ///
+ [Fact]
+ public void BuildTefcaTestPki()
+ {
+ var config = new TefcaPkiConfig
+ {
+ CertStoreName = "TEFCA_Community",
+ IntermediateCrlFilename = "TefcaTestIntermediateCrl.crl",
+ RootCrlFilename = "TefcaTestRootCrl.crl",
+ CrlBaseUrl = "http://crl.fhircerts.net/crl",
+ CaPublicCertUrl = "http://crl.fhircerts.net/certs/TEFCA_Test_CA.cer",
+ IntermediatePublicCertUrl = "http://crl.fhircerts.net/certs/intermediates/TEFCA_Test_Intermediate.cer",
+ ServerSans = new List
+ {
+ "https://fhirlabs.net/fhir/r4"
+ }
+ };
+
+ BuildTefcaPki(config);
+ }
+
+ ///
+ /// Generates TEFCA PKI for local desktop development — AIA/CRL URLs point to
+ /// host.docker.internal:5033 (Udap.Certificates.Server).
+ /// Output directory: certstores/TEFCA_Community_Desk
+ ///
+ [Fact]
+ public void BuildTefcaTestPkiDesk()
+ {
+ var config = new TefcaPkiConfig
+ {
+ CertStoreName = "TEFCA_Community_Desk",
+ IntermediateCrlFilename = "TefcaTestIntermediateCrl.crl",
+ RootCrlFilename = "TefcaTestRootCrl.crl",
+ CrlBaseUrl = "http://host.docker.internal:5033/crl",
+ CaPublicCertUrl = "http://host.docker.internal:5033/certs/TEFCA_Test_CA.cer",
+ IntermediatePublicCertUrl = "http://host.docker.internal:5033/certs/intermediates/TEFCA_Test_Intermediate.cer",
+ ServerSans = new List
+ {
+ "https://localhost:7016/fhir/r4",
+ "https://localhost:7074/fhir/r4"
+ }
+ };
+
+ BuildTefcaPki(config, distribute: true);
+ }
+
+ ///
+ /// Core PKI generation method parameterized by URL configuration.
+ ///
+ /// Hierarchy:
+ /// TEFCA-Test-CA (Root, self-signed, 4096-bit RSA)
+ /// └── TEFCA-Test-Intermediate (Intermediate CA, 4096-bit RSA)
+ /// ├── T-TRTMNT client cert (SAN: urn:oid:2.999#T-TRTMNT)
+ /// ├── T-IAS client cert (SAN: urn:oid:2.999#T-IAS)
+ /// ├── T-TREAT client cert (SAN: urn:oid:2.999#T-TREAT)
+ /// ├── T-PYMNT client cert (SAN: urn:oid:2.999#T-PYMNT)
+ /// └── server cert (SANs: fhirlabs.net, localhost:7016, localhost:7074)
+ ///
+ private void BuildTefcaPki(TefcaPkiConfig config, bool distribute = false)
+ {
+ var certStore = $"{BaseDir}/certstores/{config.CertStoreName}";
+ var intermediatesDir = $"{certStore}/intermediates";
+ var issuedDir = $"{certStore}/issued";
+ var crlDir = $"{certStore}/crl";
+
+ var rootCrlHosted = $"{config.CrlBaseUrl}/{config.RootCrlFilename}";
+ var intermediateCrlHosted = $"{config.CrlBaseUrl}/{config.IntermediateCrlFilename}";
+
+ certStore.EnsureDirectoryExists();
+ intermediatesDir.EnsureDirectoryExists();
+ issuedDir.EnsureDirectoryExists();
+ crlDir.EnsureDirectoryExists();
+
+ #region TEFCA Test Root CA
+
+ using RSA caKey = RSA.Create(4096);
+ using RSA intermediateKey = RSA.Create(4096);
+
+ var caReq = new CertificateRequest(
+ "CN=TEFCA-Test-CA, OU=Root, O=Fhir Coding, L=Portland, S=Oregon, C=US",
+ caKey,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ caReq.CertificateExtensions.Add(
+ new X509BasicConstraintsExtension(true, false, 0, true));
+
+ caReq.CertificateExtensions.Add(
+ new X509KeyUsageExtension(
+ X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign,
+ true));
+
+ caReq.CertificateExtensions.Add(
+ new X509SubjectKeyIdentifierExtension(caReq.PublicKey, false));
+
+ using var caCert = caReq.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddYears(10));
+
+ var caBytes = caCert.Export(X509ContentType.Pkcs12, "udap-test");
+ File.WriteAllBytes($"{certStore}/TEFCA_Test_CA.pfx", caBytes);
+ var caPem = PemEncoding.Write("CERTIFICATE", caCert.RawData);
+ File.WriteAllBytes($"{certStore}/TEFCA_Test_CA.cer",
+ caPem.Select(c => (byte)c).ToArray());
+
+ #endregion
+
+ #region TEFCA Test Intermediate CA
+
+ var intermediateReq = new CertificateRequest(
+ "CN=TEFCA-Test-Intermediate, OU=Intermediate, O=Fhir Coding, L=Portland, S=Oregon, C=US",
+ intermediateKey,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ intermediateReq.CertificateExtensions.Add(
+ new X509BasicConstraintsExtension(true, false, 0, true));
+
+ intermediateReq.CertificateExtensions.Add(
+ new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.KeyCertSign,
+ true));
+
+ intermediateReq.CertificateExtensions.Add(
+ new X509SubjectKeyIdentifierExtension(intermediateReq.PublicKey, false));
+
+ AddAuthorityKeyIdentifier(caCert, intermediateReq, _testOutputHelper);
+ intermediateReq.CertificateExtensions.Add(MakeCdp(rootCrlHosted));
+
+ var authorityInfoAccessBuilder = new AuthorityInformationAccessBuilder();
+ authorityInfoAccessBuilder.AddCertificateAuthorityIssuerUri(new Uri(config.CaPublicCertUrl));
+ intermediateReq.CertificateExtensions.Add(authorityInfoAccessBuilder.Build());
+
+ using var intermediateCert = intermediateReq.Create(
+ caCert,
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddYears(5),
+ new ReadOnlySpan(RandomNumberGenerator.GetBytes(16)));
+ var intermediateCertWithKey = intermediateCert.CopyWithPrivateKey(intermediateKey);
+
+ var intermediateBytes = intermediateCertWithKey.Export(X509ContentType.Pkcs12, "udap-test");
+ File.WriteAllBytes($"{intermediatesDir}/TEFCA_Test_Intermediate.pfx", intermediateBytes);
+ var intermediatePem = PemEncoding.Write("CERTIFICATE", intermediateCert.RawData);
+ File.WriteAllBytes($"{intermediatesDir}/TEFCA_Test_Intermediate.cer",
+ intermediatePem.Select(c => (byte)c).ToArray());
+
+ #endregion
+
+ #region Initial CRLs (empty, no revocations)
+
+ GenerateInitialCrl(
+ caCert, caKey,
+ $"{crlDir}/{config.RootCrlFilename}");
+
+ GenerateInitialCrl(
+ intermediateCertWithKey, intermediateKey,
+ $"{crlDir}/{config.IntermediateCrlFilename}");
+
+ #endregion
+
+ #region Client Certificate with all Exchange Purposes in SAN
+
+ // Single client cert with all XP codes plus one invalid SAN for testing
+ BuildTefcaClientCert(
+ intermediateCert, caCert, intermediateKey,
+ "all-xp",
+ new List
+ {
+ "urn:oid:2.999#T-TRTMNT",
+ "urn:oid:2.999#T-TREAT",
+ "urn:oid:2.999#T-PYMNT",
+ "urn:oid:2.999#T-HCO",
+ "urn:oid:2.999#T-HCO-CC",
+ "urn:oid:2.999#T-HCO-HED",
+ "urn:oid:2.999#T-HCO-QM",
+ "urn:oid:2.999#T-PH",
+ "urn:oid:2.999#T-PH-ECR",
+ "urn:oid:2.999#T-PH-ELR",
+ "urn:oid:2.999#T-IAS",
+ "urn:oid:2.999#T-GOVDTRM",
+ "urn:oid:2.999#INVALID"
+ },
+ $"{issuedDir}/fhirlabs.net.tefca.client",
+ intermediateCrlHosted, config.IntermediatePublicCertUrl);
+
+ #endregion
+
+ #region Server Certificate (for signing UDAP metadata)
+
+ BuildTefcaClientCert(
+ intermediateCert, caCert, intermediateKey,
+ "server",
+ config.ServerSans,
+ $"{issuedDir}/fhirlabs.net.tefca.server",
+ intermediateCrlHosted, config.IntermediatePublicCertUrl);
+
+ #endregion
+
+ #region Distribute to example projects
+
+ if (distribute)
+ {
+ var fhirLabsApi = $"{BaseDir}/../../examples/FhirLabsApi";
+ var authServer = $"{BaseDir}/../../examples/Udap.Auth.Server";
+ var certServer = $"{BaseDir}/../../examples/Udap.Certificates.Server/wwwroot";
+
+ // FhirLabsApi: only the server endcert for signing UDAP metadata
+ $"{fhirLabsApi}/CertStore/issued".EnsureDirectoryExists();
+ File.Copy($"{issuedDir}/fhirlabs.net.tefca.server.pfx",
+ $"{fhirLabsApi}/CertStore/issued/fhirlabs.net.tefca.server.pfx", true);
+
+ // Udap.Auth.Server: only the server endcert for signing UDAP metadata
+ // (anchors and intermediates go in the database via UdapDb migrations)
+ $"{authServer}/CertStore/issued".EnsureDirectoryExists();
+ File.Copy($"{issuedDir}/fhirlabs.net.tefca.server.pfx",
+ $"{authServer}/CertStore/issued/fhirlabs.net.tefca.server.pfx", true);
+
+ // Udap.Certificates.Server: CA anchor (AIA), intermediate cert (AIA), and CRLs (CDP)
+ // for local desktop resolution of certificate chain
+ $"{certServer}/certs/intermediates".EnsureDirectoryExists();
+ File.Copy($"{certStore}/TEFCA_Test_CA.cer",
+ $"{certServer}/certs/TEFCA_Test_CA.cer", true);
+ File.Copy($"{intermediatesDir}/TEFCA_Test_Intermediate.cer",
+ $"{certServer}/certs/intermediates/TEFCA_Test_Intermediate.cer", true);
+ File.Copy($"{crlDir}/{config.RootCrlFilename}",
+ $"{certServer}/crl/{config.RootCrlFilename}", true);
+ File.Copy($"{crlDir}/{config.IntermediateCrlFilename}",
+ $"{certServer}/crl/{config.IntermediateCrlFilename}", true);
+
+ _testOutputHelper.WriteLine(
+ $"[{config.CertStoreName}] Distributed TEFCA certs to FhirLabsApi, Udap.Auth.Server, and Udap.Certificates.Server");
+ }
+
+ #endregion
+ }
+
+ private X509Certificate2 BuildTefcaClientCert(
+ X509Certificate2 intermediateCert,
+ X509Certificate2 caCert,
+ RSA intermediateKey,
+ string exchangePurpose,
+ List subjectAltNames,
+ string clientCertFilePath,
+ string cdpUrl,
+ string aiaUrl)
+ {
+ var distinguishedName =
+ "CN=TEFCA-Mock, OU=TEFCA-TEST, O=Fhir Coding, L=Portland, S=Oregon, C=US";
+
+ var intermediateCertWithKey = intermediateCert.HasPrivateKey
+ ? intermediateCert
+ : intermediateCert.CopyWithPrivateKey(intermediateKey);
+
+ using RSA rsaKey = RSA.Create(2048);
+
+ var clientReq = new CertificateRequest(
+ distinguishedName,
+ rsaKey,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ clientReq.CertificateExtensions.Add(
+ new X509BasicConstraintsExtension(false, false, 0, true));
+
+ clientReq.CertificateExtensions.Add(
+ new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature,
+ true));
+
+ clientReq.CertificateExtensions.Add(
+ new X509SubjectKeyIdentifierExtension(clientReq.PublicKey, false));
+
+ AddAuthorityKeyIdentifier(intermediateCert, clientReq, _testOutputHelper);
+ clientReq.CertificateExtensions.Add(MakeCdp(cdpUrl));
+
+ var subAltNameBuilder = new SubjectAlternativeNameBuilder();
+ foreach (var san in subjectAltNames)
+ {
+ subAltNameBuilder.AddUri(new Uri(san));
+ }
+ clientReq.CertificateExtensions.Add(subAltNameBuilder.Build());
+
+ var aiaBuilder = new AuthorityInformationAccessBuilder();
+ aiaBuilder.AddCertificateAuthorityIssuerUri(new Uri(aiaUrl));
+ clientReq.CertificateExtensions.Add(aiaBuilder.Build());
+
+ var clientCert = clientReq.Create(
+ intermediateCertWithKey,
+ DateTimeOffset.UtcNow,
+ DateTimeOffset.UtcNow.AddYears(2),
+ new ReadOnlySpan(RandomNumberGenerator.GetBytes(16)));
+
+ var clientCertWithKey = clientCert.CopyWithPrivateKey(rsaKey);
+
+ var certPackage = new X509Certificate2Collection();
+ certPackage.Add(clientCertWithKey);
+ certPackage.Add(new X509Certificate2(intermediateCert.Export(X509ContentType.Cert)));
+ certPackage.Add(new X509Certificate2(caCert.Export(X509ContentType.Cert)));
+
+ var clientBytes = certPackage.Export(X509ContentType.Pkcs12, "udap-test");
+ File.WriteAllBytes($"{clientCertFilePath}.pfx", clientBytes!);
+ var clientPem = PemEncoding.Write("CERTIFICATE", clientCert.RawData);
+ File.WriteAllBytes($"{clientCertFilePath}.cer",
+ clientPem.Select(c => (byte)c).ToArray());
+
+ _testOutputHelper.WriteLine(
+ $"Generated TEFCA {exchangePurpose} client cert: {clientCertFilePath}.pfx");
+
+ return clientCert;
+ }
+
+ ///
+ /// Generates an initial empty CRL (no revocations) for the given CA certificate.
+ /// The CRL is compatible with the Udap.Pki.Cli renewal tool — it can be uploaded
+ /// to GCP and renewed using the update-crl command.
+ ///
+ private void GenerateInitialCrl(X509Certificate2 caCert, RSA caPrivateKey, string crlFilePath)
+ {
+ var bouncyCaCert = DotNetUtilities.FromX509Certificate(caCert);
+ var bouncyPrivateKey = DotNetUtilities.GetKeyPair(caPrivateKey).Private;
+
+ var crlGen = new X509V2CrlGenerator();
+ var now = DateTime.UtcNow;
+ crlGen.SetIssuerDN(bouncyCaCert.SubjectDN);
+ crlGen.SetThisUpdate(now);
+ crlGen.SetNextUpdate(now.AddDays(30));
+
+ // No revocations in initial CRL
+
+ crlGen.AddExtension(X509Extensions.AuthorityKeyIdentifier,
+ false,
+ new AuthorityKeyIdentifierStructure(bouncyCaCert.GetPublicKey()));
+
+ crlGen.AddExtension(X509Extensions.CrlNumber, false, new CrlNumber(BigInteger.One));
+
+ var crl = crlGen.Generate(new Asn1SignatureFactory("SHA256WithRSAEncryption", bouncyPrivateKey));
+ File.WriteAllBytes(crlFilePath, crl.GetEncoded());
+
+ _testOutputHelper.WriteLine($"Generated initial CRL: {crlFilePath}");
+ }
+
+ private class TefcaPkiConfig
+ {
+ public required string CertStoreName { get; init; }
+ public required string IntermediateCrlFilename { get; init; }
+ public required string RootCrlFilename { get; init; }
+ public required string CrlBaseUrl { get; init; }
+ public required string CaPublicCertUrl { get; init; }
+ public required string IntermediatePublicCertUrl { get; init; }
+ public required List ServerSans { get; init; }
+ }
+}
diff --git a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs
index 9ac9fa6e6..ee5e5fc92 100644
--- a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs
+++ b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerCommunityTest.cs
@@ -144,7 +144,7 @@ public async Task GetCommunitiesTest()
var response = await client.GetAsync("fhir/r4/.well-known/udap/communities");
response.EnsureSuccessStatusCode();
var communities = await response.Content.ReadFromJsonAsync>();
- Assert.Equal(7, communities!.Count);
+ Assert.Equal(8, communities!.Count);
Assert.Contains("udap://fhirlabs1/", communities);
Assert.Contains("udap://Provider2", communities);
diff --git a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs
index 0dca051a7..e9c255a14 100644
--- a/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs
+++ b/_tests/UdapMetadata.Tests/FhirLabsApi/UdapControllerTests.cs
@@ -12,6 +12,7 @@
using System.Net.Http.Json;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
+using System.Text.Json;
using System.Text.RegularExpressions;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Hosting;
@@ -277,6 +278,50 @@ public async Task udap_certifications_requiredTest()
Assert.Equal(new Uri("http://MyUdapCertification"), uriCertificationsRequired);
}
+ ///
+ /// SOP: Facilitated FHIR Implementation v2.0 — Section 6.11 Discovery #2-3
+ /// When queried with ?community=urn:oid:2.16.840.1.113883.3.7204.1.5,
+ /// the TEFCA community metadata MUST include the basic-app-certification URI
+ /// in both udap_certifications_supported and udap_certifications_required.
+ ///
+ [Fact]
+ public async Task TefcaCommunity_Certifications_OverrideRootDefaults()
+ {
+ var httpClient = _fixture.CreateClient();
+
+ var response = await httpClient.GetAsync(
+ "fhir/r4/.well-known/udap?community=urn:oid:2.16.840.1.113883.3.7204.1.5");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var json = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(json);
+
+ const string basicAppCert = "https://rce.sequoiaproject.org/udap/profiles/basic-app-certification";
+
+ // TEFCA community must include basic-app-certification in certifications_supported
+ var certSupported = json.RootElement
+ .GetProperty("udap_certifications_supported")
+ .EnumerateArray()
+ .Select(e => e.GetString())
+ .ToList();
+
+ Assert.Contains(basicAppCert, certSupported);
+
+ // TEFCA community must include basic-app-certification in certifications_required
+ var certRequired = json.RootElement
+ .GetProperty("udap_certifications_required")
+ .EnumerateArray()
+ .Select(e => e.GetString())
+ .ToList();
+
+ Assert.Contains(basicAppCert, certRequired);
+
+ // TEFCA community should NOT include the root-level test certifications
+ Assert.DoesNotContain("http://MyUdapCertification", certSupported);
+ Assert.DoesNotContain("http://MyUdapCertification2", certSupported);
+ }
+
[Fact]
public async Task grant_types_supportedTest()
{
diff --git a/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs b/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs
index 41e65621c..584eba190 100644
--- a/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs
+++ b/_tests/UdapServer.Tests/Conformance/Basic/AuthorizationExtensionEnforcementTests.cs
@@ -44,13 +44,24 @@ public AuthorizationExtensionEnforcementTests(ITestOutputHelper testOutputHelper
[Fact]
public async Task TokenRequest_WithRequiredB2B_WithValidExtension_Succeeds()
{
- var pipeline = BuildPipeline(new ServerSettings
- {
- DefaultSystemScopes = "udap",
- DefaultUserScopes = "udap",
- SsraaVersion = SsraaVersion.V1_1,
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- });
+ var communityValidator = new TestCommunityTokenValidator(
+ "udap://fhirlabs.net",
+ new CommunityValidationRules
+ {
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+
+ var pipeline = BuildPipeline(
+ new ServerSettings
+ {
+ DefaultSystemScopes = "udap",
+ DefaultUserScopes = "udap",
+ SsraaVersion = SsraaVersion.V1_1
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(communityValidator);
+ });
var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test");
var regResult = await RegisterClient(pipeline, clientCert);
@@ -80,13 +91,24 @@ public async Task TokenRequest_WithRequiredB2B_WithValidExtension_Succeeds()
[Fact]
public async Task TokenRequest_WithRequiredB2B_WithoutExtension_Fails()
{
- var pipeline = BuildPipeline(new ServerSettings
- {
- DefaultSystemScopes = "udap",
- DefaultUserScopes = "udap",
- SsraaVersion = SsraaVersion.V1_1,
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- });
+ var communityValidator = new TestCommunityTokenValidator(
+ "udap://fhirlabs.net",
+ new CommunityValidationRules
+ {
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+
+ var pipeline = BuildPipeline(
+ new ServerSettings
+ {
+ DefaultSystemScopes = "udap",
+ DefaultUserScopes = "udap",
+ SsraaVersion = SsraaVersion.V1_1
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(communityValidator);
+ });
var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test");
var regResult = await RegisterClient(pipeline, clientCert);
@@ -111,13 +133,24 @@ public async Task TokenRequest_WithRequiredB2B_WithoutExtension_Fails()
[Fact]
public async Task TokenRequest_WithRequiredB2B_WithInvalidExtension_MissingOrganizationId_Fails()
{
- var pipeline = BuildPipeline(new ServerSettings
- {
- DefaultSystemScopes = "udap",
- DefaultUserScopes = "udap",
- SsraaVersion = SsraaVersion.V1_1,
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- });
+ var communityValidator = new TestCommunityTokenValidator(
+ "udap://fhirlabs.net",
+ new CommunityValidationRules
+ {
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+
+ var pipeline = BuildPipeline(
+ new ServerSettings
+ {
+ DefaultSystemScopes = "udap",
+ DefaultUserScopes = "udap",
+ SsraaVersion = SsraaVersion.V1_1
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(communityValidator);
+ });
var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test");
var regResult = await RegisterClient(pipeline, clientCert);
@@ -147,14 +180,14 @@ public async Task TokenRequest_WithRequiredB2B_WithInvalidExtension_MissingOrgan
}
[Fact]
- public async Task TokenRequest_NoRequiredExtensions_WithoutExtension_Succeeds()
+ public async Task TokenRequest_NoCommunityValidator_WithoutExtension_Succeeds()
{
+ // No community validator registered — no enforcement
var pipeline = BuildPipeline(new ServerSettings
{
DefaultSystemScopes = "udap",
DefaultUserScopes = "udap",
- SsraaVersion = SsraaVersion.V1_1,
- AuthorizationExtensionsRequired = null
+ SsraaVersion = SsraaVersion.V1_1
});
var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test");
@@ -175,28 +208,31 @@ public async Task TokenRequest_NoRequiredExtensions_WithoutExtension_Succeeds()
}
[Fact]
- public async Task TokenRequest_CommunityOverride_RequiresB2B_WithoutExtension_Fails()
+ public async Task TokenRequest_CommunityValidator_RequiresB2B_WithoutExtension_Fails()
{
- var pipeline = BuildPipeline(new ServerSettings
- {
- DefaultSystemScopes = "udap",
- DefaultUserScopes = "udap",
- SsraaVersion = SsraaVersion.V1_1,
- AuthorizationExtensionsRequired = null,
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://fhirlabs.net",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- }
- ]
- });
+ var communityValidator = new TestCommunityTokenValidator(
+ "udap://fhirlabs.net",
+ new CommunityValidationRules
+ {
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+
+ var pipeline = BuildPipeline(
+ new ServerSettings
+ {
+ DefaultSystemScopes = "udap",
+ DefaultUserScopes = "udap",
+ SsraaVersion = SsraaVersion.V1_1
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(communityValidator);
+ });
var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test");
var regResult = await RegisterClient(pipeline, clientCert);
- // Token request without extension — community requires it
+ // Token request without extension — community validator requires it
var clientRequest = AccessTokenRequestForClientCredentialsBuilder.Create(
regResult.ClientId,
IdentityServerPipeline.TokenEndpoint,
@@ -214,24 +250,27 @@ public async Task TokenRequest_CommunityOverride_RequiresB2B_WithoutExtension_Fa
}
[Fact]
- public async Task TokenRequest_CommunityOverride_RequiresB2B_WithValidExtension_Succeeds()
+ public async Task TokenRequest_CommunityValidator_RequiresB2B_WithValidExtension_Succeeds()
{
- var pipeline = BuildPipeline(new ServerSettings
- {
- DefaultSystemScopes = "udap",
- DefaultUserScopes = "udap",
- SsraaVersion = SsraaVersion.V1_1,
- AuthorizationExtensionsRequired = null,
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://fhirlabs.net",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#TREAT"]
- }
- ]
- });
+ var communityValidator = new TestCommunityTokenValidator(
+ "udap://fhirlabs.net",
+ new CommunityValidationRules
+ {
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
+ AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#TREAT" }
+ });
+
+ var pipeline = BuildPipeline(
+ new ServerSettings
+ {
+ DefaultSystemScopes = "udap",
+ DefaultUserScopes = "udap",
+ SsraaVersion = SsraaVersion.V1_1
+ },
+ configureServices: services =>
+ {
+ services.AddSingleton(communityValidator);
+ });
var clientCert = new X509Certificate2("CertStore/issued/fhirlabs.net.client.pfx", "udap-test");
var regResult = await RegisterClient(pipeline, clientCert);
@@ -431,4 +470,29 @@ private static async Task RegisterClient(
}
#endregion
+
+ ///
+ /// Simple community token validator for integration tests that provides rules
+ /// via and always
+ /// returns success from .
+ ///
+ private class TestCommunityTokenValidator : ICommunityTokenValidator
+ {
+ private readonly string _community;
+ private readonly CommunityValidationRules _rules;
+
+ public TestCommunityTokenValidator(string community, CommunityValidationRules rules)
+ {
+ _community = community;
+ _rules = rules;
+ }
+
+ public bool AppliesToCommunity(string communityName) => communityName == _community;
+
+ public CommunityValidationRules? GetValidationRules(string? grantType) => _rules;
+
+ public Task ValidateAsync(
+ UdapAuthorizationExtensionValidationContext context)
+ => Task.FromResult(AuthorizationExtensionValidationResult.Success());
+ }
}
diff --git a/_tests/UdapServer.Tests/UdapServer.Tests.csproj b/_tests/UdapServer.Tests/UdapServer.Tests.csproj
index db7594817..67c5a2772 100644
--- a/_tests/UdapServer.Tests/UdapServer.Tests.csproj
+++ b/_tests/UdapServer.Tests/UdapServer.Tests.csproj
@@ -117,6 +117,8 @@
+
+
diff --git a/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs b/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs
index 1a82edd85..3d7398741 100644
--- a/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs
+++ b/_tests/UdapServer.Tests/Validators/DefaultUdapAuthorizationExtensionValidatorTests.cs
@@ -13,12 +13,10 @@
using NSubstitute;
using Udap.Model;
using Udap.Model.UdapAuthenticationExtensions;
-using Udap.Server.Configuration;
using Udap.Server.Storage.Stores;
using Udap.Server.Validation;
using Udap.Server.Validation.Default;
using Udap.Tefca.Model;
-using UdapServer.Tests.Common;
namespace UdapServer.Tests.Validators;
@@ -27,12 +25,12 @@ public class DefaultUdapAuthorizationExtensionValidatorTests
private readonly ILogger _logger =
Substitute.For>();
- #region No Required Extensions
+ #region No Community Validator — No Enforcement
[Fact]
- public async Task NoRequiredExtensions_NoExtensionsPresent_Succeeds()
+ public async Task NoCommunityValidator_NoExtensionsPresent_Succeeds()
{
- var validator = CreateValidator(new ServerSettings());
+ var validator = CreateValidator();
var context = CreateContext();
var result = await validator.ValidateAsync(context);
@@ -41,9 +39,9 @@ public async Task NoRequiredExtensions_NoExtensionsPresent_Succeeds()
}
[Fact]
- public async Task NoRequiredExtensions_ExtensionsPresent_Succeeds()
+ public async Task NoCommunityValidator_ExtensionsPresent_Succeeds()
{
- var validator = CreateValidator(new ServerSettings());
+ var validator = CreateValidator();
var context = CreateContext(extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -52,23 +50,24 @@ public async Task NoRequiredExtensions_ExtensionsPresent_Succeeds()
}
[Fact]
- public async Task NullRequiredExtensions_Succeeds()
+ public async Task NoCommunityValidator_AnyPOUCode_Succeeds()
{
- var settings = new ServerSettings { AuthorizationExtensionsRequired = null };
- var validator = CreateValidator(settings);
- var context = CreateContext();
-
- var result = await validator.ValidateAsync(context);
+ // Without a community validator, no POU validation occurs
+ var validator = CreateValidator();
- Assert.True(result.IsValid);
- }
+ var b2b = new HL7B2BAuthorizationExtension
+ {
+ OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99"
+ };
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT");
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#ETREAT");
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#HPAYMT");
- [Fact]
- public async Task EmptyRequiredExtensions_Succeeds()
- {
- var settings = new ServerSettings { AuthorizationExtensionsRequired = [] };
- var validator = CreateValidator(settings);
- var context = CreateContext();
+ var extensions = new Dictionary
+ {
+ [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
+ };
+ var context = CreateContext(extensions: extensions);
var result = await validator.ValidateAsync(context);
@@ -77,17 +76,16 @@ public async Task EmptyRequiredExtensions_Succeeds()
#endregion
- #region Required Extension Presence
+ #region Required Extension Presence (via Community Validator)
[Fact]
- public async Task RequiredExtension_Missing_Fails()
+ public async Task CommunityValidator_RequiredExtension_Missing_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext();
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1");
var result = await validator.ValidateAsync(context);
@@ -97,14 +95,13 @@ public async Task RequiredExtension_Missing_Fails()
}
[Fact]
- public async Task RequiredExtension_NullExtensions_Fails()
+ public async Task CommunityValidator_RequiredExtension_NullExtensions_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: null);
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: null);
var result = await validator.ValidateAsync(context);
@@ -114,14 +111,13 @@ public async Task RequiredExtension_NullExtensions_Fails()
}
[Fact]
- public async Task RequiredExtension_EmptyExtensions_Fails()
+ public async Task CommunityValidator_RequiredExtension_EmptyExtensions_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: new Dictionary());
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: new Dictionary());
var result = await validator.ValidateAsync(context);
@@ -130,14 +126,13 @@ public async Task RequiredExtension_EmptyExtensions_Fails()
}
[Fact]
- public async Task RequiredExtension_Present_Succeeds()
+ public async Task CommunityValidator_RequiredExtension_Present_Succeeds()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions());
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -145,18 +140,17 @@ public async Task RequiredExtension_Present_Succeeds()
}
[Fact]
- public async Task MultipleRequiredExtensions_OneMissing_Fails()
+ public async Task CommunityValidator_MultipleRequiredExtensions_OneMissing_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired =
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions:
[
UdapConstants.UdapAuthorizationExtensions.Hl7B2B,
UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER
- ]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions());
+ ]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -166,14 +160,13 @@ public async Task MultipleRequiredExtensions_OneMissing_Fails()
}
[Fact]
- public async Task RequiredExtension_WrongKeyPresent_Fails()
+ public async Task CommunityValidator_RequiredExtension_WrongKeyPresent_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions());
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -188,11 +181,10 @@ public async Task RequiredExtension_WrongKeyPresent_Fails()
[Fact]
public async Task B2BExtension_MissingOrganizationId_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
var b2b = new HL7B2BAuthorizationExtension
{
@@ -204,7 +196,7 @@ public async Task B2BExtension_MissingOrganizationId_Fails()
{
[UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
};
- var context = CreateContext(extensions: extensions);
+ var context = CreateContext(communityId: "1", extensions: extensions);
var result = await validator.ValidateAsync(context);
@@ -215,11 +207,10 @@ public async Task B2BExtension_MissingOrganizationId_Fails()
[Fact]
public async Task B2BExtension_MissingPurposeOfUse_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
var b2b = new HL7B2BAuthorizationExtension
{
@@ -231,7 +222,7 @@ public async Task B2BExtension_MissingPurposeOfUse_Fails()
{
[UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
};
- var context = CreateContext(extensions: extensions);
+ var context = CreateContext(communityId: "1", extensions: extensions);
var result = await validator.ValidateAsync(context);
@@ -242,11 +233,10 @@ public async Task B2BExtension_MissingPurposeOfUse_Fails()
[Fact]
public async Task B2BExtension_MissingVersion_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
var b2b = new HL7B2BAuthorizationExtension
{
@@ -259,7 +249,7 @@ public async Task B2BExtension_MissingVersion_Fails()
{
[UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
};
- var context = CreateContext(extensions: extensions);
+ var context = CreateContext(communityId: "1", extensions: extensions);
var result = await validator.ValidateAsync(context);
@@ -270,12 +260,11 @@ public async Task B2BExtension_MissingVersion_Fails()
[Fact]
public async Task B2BExtension_AllRequiredFieldsPresent_Succeeds()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions());
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -285,11 +274,10 @@ public async Task B2BExtension_AllRequiredFieldsPresent_Succeeds()
[Fact]
public async Task B2BUserExtension_MissingUserPerson_Fails()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- };
- var validator = CreateValidator(settings);
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
var b2bUser = new HL7B2BUserAuthorizationExtension();
b2bUser.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT");
@@ -298,7 +286,7 @@ public async Task B2BUserExtension_MissingUserPerson_Fails()
{
[UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] = b2bUser
};
- var context = CreateContext(extensions: extensions);
+ var context = CreateContext(communityId: "1", extensions: extensions);
var result = await validator.ValidateAsync(context);
@@ -309,17 +297,16 @@ public async Task B2BUserExtension_MissingUserPerson_Fails()
[Fact]
public async Task UnknownExtensionType_NotStructurallyValidated_Succeeds()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = ["custom-extension"]
- };
- var validator = CreateValidator(settings);
+ var (clientStore, communityValidator) = SetupCommunityValidator(
+ requiredExtensions: ["custom-extension"]);
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
var extensions = new Dictionary
{
["custom-extension"] = new { version = "1", custom_field = "value" }
};
- var context = CreateContext(extensions: extensions);
+ var context = CreateContext(communityId: "1", extensions: extensions);
var result = await validator.ValidateAsync(context);
@@ -331,26 +318,21 @@ public async Task UnknownExtensionType_NotStructurallyValidated_Succeeds()
#region Community Settings
[Fact]
- public async Task CommunityOverride_MatchingCommunity_UsesOverrideSettings()
+ public async Task CommunityValidator_MatchingCommunity_UsesValidatorRules()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
- var settings = new ServerSettings
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = null,
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- }
- ]
- };
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [communityValidator]);
var context = CreateContext(communityId: "1");
var result = await validator.ValidateAsync(context);
@@ -360,27 +342,22 @@ public async Task CommunityOverride_MatchingCommunity_UsesOverrideSettings()
}
[Fact]
- public async Task CommunityOverride_MatchingCommunity_WithValidExtension_Succeeds()
+ public async Task CommunityValidator_MatchingCommunity_WithValidExtension_Succeeds()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
- var settings = new ServerSettings
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = null,
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#TREAT"]
- }
- ]
- };
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
+ AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#TREAT" }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [communityValidator]);
var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -389,153 +366,109 @@ public async Task CommunityOverride_MatchingCommunity_WithValidExtension_Succeed
}
[Fact]
- public async Task CommunityOverride_NonMatchingCommunity_FallsBackToGlobal()
+ public async Task CommunityValidator_NonMatchingCommunity_NoEnforcement()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
+ clientStore.GetCommunityName("2").Returns(Task.FromResult("udap://community-b"));
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = null
- }
- ]
- };
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-b").Returns(false);
- var validator = CreateValidator(settings, clientStore);
- // Client is in community 2, which doesn't match any CommunitySettings
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ // No extensions — passes because no validator matched
var context = CreateContext(communityId: "2");
var result = await validator.ValidateAsync(context);
- // Falls back to global which requires hl7-b2b
- Assert.False(result.IsValid);
- Assert.Contains("hl7-b2b", result.ErrorDescription);
+ Assert.True(result.IsValid);
}
[Fact]
- public async Task CommunityOverride_NullCommunityId_FallsBackToGlobal()
+ public async Task CommunityValidator_NullCommunityId_NoEnforcement()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = []
- }
- ]
- };
-
- var validator = CreateValidator(settings);
+ var validator = CreateValidator();
var context = CreateContext(communityId: null);
var result = await validator.ValidateAsync(context);
- // Falls back to global which requires hl7-b2b
- Assert.False(result.IsValid);
+ Assert.True(result.IsValid);
}
[Fact]
- public async Task CommunityOverride_OverrideHasNoRequired_GlobalHasRequired_UsesOverride()
+ public async Task CommunityValidator_RulesOverride_EmptyRequired_Succeeds()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
- var settings = new ServerSettings
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = []
- }
- ]
- };
+ RequiredExtensions = new HashSet() // empty — no extensions required
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [communityValidator]);
var context = CreateContext(communityId: "1");
var result = await validator.ValidateAsync(context);
- // Community override has empty required list — no extensions needed
Assert.True(result.IsValid);
}
[Fact]
- public async Task CommunityOverride_NullOverrideRequired_FallsBackToGlobal()
+ public async Task CommunityValidator_NullRules_NoEnforcement()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = null
- }
- ]
- };
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns((CommunityValidationRules?)null);
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ // No extensions — passes because validator returned null rules
var context = CreateContext(communityId: "1");
var result = await validator.ValidateAsync(context);
- // Community override has null required — falls back to global
- Assert.False(result.IsValid);
- Assert.Contains("hl7-b2b", result.ErrorDescription);
+ Assert.True(result.IsValid);
}
[Fact]
- public async Task CommunityOverride_MultipleCommunities_CorrectOneSelected()
+ public async Task CommunityValidator_MultipleCommunities_CorrectOneSelected()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
- clientStore.GetCommunityId("udap://community-b")
- .Returns(Task.FromResult(2));
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+ clientStore.GetCommunityName("2").Returns(Task.FromResult("udap://community-b"));
- var settings = new ServerSettings
+ var validatorA = Substitute.For();
+ validatorA.AppliesToCommunity("udap://community-a").Returns(true);
+ validatorA.AppliesToCommunity("udap://community-b").Returns(false);
+ validatorA.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = null,
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- },
- new CommunityServerSettings
- {
- Community = "udap://community-b",
- AuthorizationExtensionsRequired =
- [
- UdapConstants.UdapAuthorizationExtensions.Hl7B2B,
- TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS
- ]
- }
- ]
- };
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+
+ var validatorB = Substitute.For();
+ validatorB.AppliesToCommunity("udap://community-a").Returns(false);
+ validatorB.AppliesToCommunity("udap://community-b").Returns(true);
+ validatorB.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
+ {
+ RequiredExtensions = new HashSet
+ {
+ UdapConstants.UdapAuthorizationExtensions.Hl7B2B,
+ TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS
+ }
+ });
+ validatorB.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
// Client in community-b should need both hl7-b2b and tefca-ias
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [validatorA, validatorB]);
var context = CreateContext(communityId: "2", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -545,49 +478,45 @@ public async Task CommunityOverride_MultipleCommunities_CorrectOneSelected()
}
[Fact]
- public async Task CommunityOverride_CommunityNotFoundInStore_FallsBackToGlobal()
+ public async Task CommunityValidator_CommunityNameNotResolved_NoEnforcement()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(null));
-
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = []
- }
- ]
- };
+ clientStore.GetCommunityName("1").Returns(Task.FromResult(null));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore);
+ // No extensions — passes because community name didn't resolve
var context = CreateContext(communityId: "1");
var result = await validator.ValidateAsync(context);
- // Community name didn't resolve to an ID that matches "1", falls back to global
- Assert.False(result.IsValid);
- Assert.Contains("hl7-b2b", result.ErrorDescription);
+ Assert.True(result.IsValid);
}
#endregion
- #region Grant Type Specific Extensions
+ #region Grant Type Specific Extensions (via Community Validator)
[Fact]
public async Task GrantTypeSpecific_ClientCredentials_RequiresB2B_Succeeds()
{
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions(), grantType: "client_credentials");
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+ communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules
+ {
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions(), grantType: "client_credentials");
var result = await validator.ValidateAsync(context);
@@ -597,13 +526,18 @@ public async Task GrantTypeSpecific_ClientCredentials_RequiresB2B_Succeeds()
[Fact]
public async Task GrantTypeSpecific_ClientCredentials_MissingB2B_Fails()
{
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(grantType: "client_credentials");
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", grantType: "client_credentials");
var result = await validator.ValidateAsync(context);
@@ -614,12 +548,19 @@ public async Task GrantTypeSpecific_ClientCredentials_MissingB2B_Fails()
[Fact]
public async Task GrantTypeSpecific_AuthorizationCode_RequiresB2BUser_Succeeds()
{
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules
{
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- };
- var validator = CreateValidator(settings);
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
var b2bUser = new HL7B2BUserAuthorizationExtension();
b2bUser.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT");
@@ -629,7 +570,7 @@ public async Task GrantTypeSpecific_AuthorizationCode_RequiresB2BUser_Succeeds()
{
[UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER] = b2bUser
};
- var context = CreateContext(extensions: extensions, grantType: "authorization_code");
+ var context = CreateContext(communityId: "1", extensions: extensions, grantType: "authorization_code");
var result = await validator.ValidateAsync(context);
@@ -639,13 +580,18 @@ public async Task GrantTypeSpecific_AuthorizationCode_RequiresB2BUser_Succeeds()
[Fact]
public async Task GrantTypeSpecific_AuthorizationCode_MissingB2BUser_Fails()
{
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules
{
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(grantType: "authorization_code");
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER }
+ });
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", grantType: "authorization_code");
var result = await validator.ValidateAsync(context);
@@ -657,13 +603,18 @@ public async Task GrantTypeSpecific_AuthorizationCode_MissingB2BUser_Fails()
public async Task GrantTypeSpecific_AuthorizationCode_WrongExtension_Fails()
{
// Client sends hl7-b2b but authorization_code requires hl7-b2b-user
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules
{
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions(), grantType: "authorization_code");
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER }
+ });
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions(), grantType: "authorization_code");
var result = await validator.ValidateAsync(context);
@@ -672,54 +623,30 @@ public async Task GrantTypeSpecific_AuthorizationCode_WrongExtension_Fails()
}
[Fact]
- public async Task GrantTypeSpecific_SSRAA_AuthorizationCode_NoExtensionRequired_Succeeds()
+ public async Task GrantTypeSpecific_AuthorizationCode_NoExtensionRequired_Succeeds()
{
- // SSRAA requires hl7-b2b for client_credentials but NOT for authorization_code
- var settings = new ServerSettings
- {
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- // AuthorizationCodeExtensionsRequired is null — no extension needed
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(grantType: "authorization_code"); // no extensions
-
- var result = await validator.ValidateAsync(context);
-
- Assert.True(result.IsValid);
- }
+ // Community validator requires hl7-b2b for client_credentials but NOT for authorization_code
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
- [Fact]
- public async Task GrantTypeSpecific_FallsBackToGeneral_WhenNoGrantSpecific()
- {
- // Only general AuthorizationExtensionsRequired set, no grant-specific
- var settings = new ServerSettings
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(grantType: "client_credentials");
-
- var result = await validator.ValidateAsync(context);
-
- Assert.False(result.IsValid);
- Assert.Contains("hl7-b2b", result.ErrorDescription);
- }
-
- [Fact]
- public async Task GrantTypeSpecific_OverridesGeneral()
- {
- // General requires hl7-b2b, but grant-specific for client_credentials is empty
- var settings = new ServerSettings
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B }
+ });
+ communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- ClientCredentialsExtensionsRequired = []
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(grantType: "client_credentials");
+ RequiredExtensions = null // no extension needed for auth code
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", grantType: "authorization_code"); // no extensions
var result = await validator.ValidateAsync(context);
- // Grant-specific empty set overrides the general requirement
Assert.True(result.IsValid);
}
@@ -727,27 +654,19 @@ public async Task GrantTypeSpecific_OverridesGeneral()
public async Task GrantTypeSpecific_Community_ClientCredentials_RequiresB2B()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5")
- .Returns(Task.FromResult(10));
+ clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5"));
- var settings = new ServerSettings
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "urn:oid:2.16.840.1.113883.3.7204.1.5",
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER],
- AllowedPurposeOfUse =
- [
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT"
- ]
- }
- ]
- };
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
+ AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT" }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [communityValidator]);
var b2b = new HL7B2BAuthorizationExtension
{
@@ -771,23 +690,18 @@ public async Task GrantTypeSpecific_Community_ClientCredentials_RequiresB2B()
public async Task GrantTypeSpecific_Community_AuthorizationCode_RequiresB2BUser()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5")
- .Returns(Task.FromResult(10));
+ clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5"));
- var settings = new ServerSettings
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true);
+ communityValidator.GetValidationRules("authorization_code").Returns(new CommunityValidationRules
{
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "urn:oid:2.16.840.1.113883.3.7204.1.5",
- ClientCredentialsExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AuthorizationCodeExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER]
- }
- ]
- };
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2BUSER }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [communityValidator]);
// Client sends hl7-b2b (wrong for authorization_code)
var context = CreateContext(communityId: "10", extensions: CreateValidB2BExtensions(), grantType: "authorization_code");
@@ -799,49 +713,49 @@ public async Task GrantTypeSpecific_Community_AuthorizationCode_RequiresB2BUser(
}
[Fact]
- public async Task GrantTypeSpecific_Community_FallsBackToGeneralCommunity()
+ public async Task GrantTypeSpecific_Community_NullGrantRules_NoEnforcement()
{
- // Community has AuthorizationExtensionsRequired but no grant-specific
+ // Community validator applies but returns null rules for the grant type — no enforcement
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
- var settings = new ServerSettings
- {
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B]
- }
- ]
- };
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns((CommunityValidationRules?)null);
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
+ var validator = CreateValidator(clientStore, [communityValidator]);
var context = CreateContext(communityId: "1", grantType: "client_credentials");
var result = await validator.ValidateAsync(context);
- // Falls back to community's general AuthorizationExtensionsRequired
- Assert.False(result.IsValid);
- Assert.Contains("hl7-b2b", result.ErrorDescription);
+ // No enforcement — community validator returned null rules
+ Assert.True(result.IsValid);
}
#endregion
- #region Purpose of Use Validation
+ #region Purpose of Use Validation (via Community Validators)
[Fact]
- public async Task AllowedPurposeOfUse_ValidCode_Succeeds()
+ public async Task CommunityValidator_AllowedPurposeOfUse_ValidCode_Succeeds()
{
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#TREAT", "urn:oid:2.16.840.1.113883.5.8#ETREAT"]
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions());
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
+ AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#TREAT", "urn:oid:2.16.840.1.113883.5.8#ETREAT" }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -849,16 +763,24 @@ public async Task AllowedPurposeOfUse_ValidCode_Succeeds()
}
[Fact]
- public async Task AllowedPurposeOfUse_DisallowedCode_Fails()
+ public async Task CommunityValidator_AllowedPurposeOfUse_DisallowedCode_Fails()
{
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#ETREAT"]
- };
- var validator = CreateValidator(settings);
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
+ AllowedPurposeOfUse = new HashSet { "urn:oid:2.16.840.1.113883.5.8#ETREAT" }
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
// CreateValidB2BExtensions uses TREAT, which is not in the allowed set
- var context = CreateContext(extensions: CreateValidB2BExtensions());
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -869,48 +791,23 @@ public async Task AllowedPurposeOfUse_DisallowedCode_Fails()
}
[Fact]
- public async Task AllowedPurposeOfUse_NullAllowList_AnyCodeAccepted()
+ public async Task CommunityValidator_NullAllowList_AnyCodeAccepted()
{
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse = null
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions());
-
- var result = await validator.ValidateAsync(context);
-
- Assert.True(result.IsValid);
- }
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
- [Fact]
- public async Task AllowedPurposeOfUse_EmptyAllowList_AllCodesRejected()
- {
- var settings = new ServerSettings
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse = []
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions());
-
- var result = await validator.ValidateAsync(context);
-
- Assert.False(result.IsValid);
- Assert.Contains("disallowed", result.ErrorDescription);
- }
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
+ AllowedPurposeOfUse = null
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- [Fact]
- public async Task MaxPurposeOfUseCount_WithinLimit_Succeeds()
- {
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- MaxPurposeOfUseCount = 2
- };
- var validator = CreateValidator(settings);
- var context = CreateContext(extensions: CreateValidB2BExtensions()); // has 1
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
var result = await validator.ValidateAsync(context);
@@ -918,14 +815,22 @@ public async Task MaxPurposeOfUseCount_WithinLimit_Succeeds()
}
[Fact]
- public async Task MaxPurposeOfUseCount_ExceedsLimit_Fails()
+ public async Task CommunityValidator_MaxPurposeOfUseCount_ExceedsLimit_Fails()
{
- var settings = new ServerSettings
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://community-a"));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("udap://community-a").Returns(true);
+ communityValidator.GetValidationRules("client_credentials").Returns(new CommunityValidationRules
{
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
+ RequiredExtensions = new HashSet { UdapConstants.UdapAuthorizationExtensions.Hl7B2B },
MaxPurposeOfUseCount = 1
- };
- var validator = CreateValidator(settings);
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
+
+ var validator = CreateValidator(clientStore, [communityValidator]);
var b2b = new HL7B2BAuthorizationExtension
{
@@ -938,7 +843,7 @@ public async Task MaxPurposeOfUseCount_ExceedsLimit_Fails()
{
[UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
};
- var context = CreateContext(extensions: extensions);
+ var context = CreateContext(communityId: "1", extensions: extensions);
var result = await validator.ValidateAsync(context);
@@ -946,205 +851,124 @@ public async Task MaxPurposeOfUseCount_ExceedsLimit_Fails()
Assert.Contains("maximum allowed is 1", result.ErrorDescription);
}
- [Fact]
- public async Task MaxPurposeOfUseCount_Null_NoLimit()
- {
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- MaxPurposeOfUseCount = null
- };
- var validator = CreateValidator(settings);
+ #endregion
- var b2b = new HL7B2BAuthorizationExtension
- {
- OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99"
- };
- b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT");
- b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#ETREAT");
- b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#HPAYMT");
+ #region ErrorExtensions
- var extensions = new Dictionary
+ [Fact]
+ public void ErrorExtensions_Failure_Factory_Carries_Extensions()
+ {
+ var errorExtensions = new Dictionary
{
- [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
+ ["hl7-b2b"] = new
+ {
+ consent_required = new[] { "urn:oid:2.16.840.1.113883.3.7204.1.1.1.1.2.1" },
+ consent_form = "https://tefca.example.com/consent/form.pdf"
+ }
};
- var context = CreateContext(extensions: extensions);
- var result = await validator.ValidateAsync(context);
+ var result = AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ "Consent policy required",
+ errorExtensions);
- Assert.True(result.IsValid);
+ Assert.False(result.IsValid);
+ Assert.Equal("invalid_grant", result.Error);
+ Assert.Equal("Consent policy required", result.ErrorDescription);
+ Assert.NotNull(result.ErrorExtensions);
+ Assert.True(result.ErrorExtensions.ContainsKey("hl7-b2b"));
}
[Fact]
- public async Task TefcaStyle_MaxCount1_AllowedXPCodes_ValidRequest_Succeeds()
+ public void ErrorExtensions_Standard_Failure_Has_Null_Extensions()
{
- var clientStore = Substitute.For();
- clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5")
- .Returns(Task.FromResult(10));
-
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "urn:oid:2.16.840.1.113883.3.7204.1.5",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- MaxPurposeOfUseCount = 1,
- AllowedPurposeOfUse =
- [
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT",
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TRTMNT",
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PYMNT",
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-IAS"
- ]
- }
- ]
- };
-
- var validator = CreateValidator(settings, clientStore);
+ var result = AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant",
+ "Missing required extension");
- var b2b = new HL7B2BAuthorizationExtension
- {
- OrganizationId = "Organization/1.2.3",
- OrganizationName = "Test QHIN"
- };
- b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT");
-
- var extensions = new Dictionary
- {
- [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
- };
- var context = CreateContext(communityId: "10", extensions: extensions);
+ Assert.False(result.IsValid);
+ Assert.Null(result.ErrorExtensions);
+ }
- var result = await validator.ValidateAsync(context);
+ [Fact]
+ public void ErrorExtensions_Success_Has_Null_Extensions()
+ {
+ var result = AuthorizationExtensionValidationResult.Success();
Assert.True(result.IsValid);
+ Assert.Null(result.ErrorExtensions);
}
+ #endregion
+
+ #region CommunityValidatorsAlwaysRun
+
[Fact]
- public async Task TefcaStyle_MaxCount1_MultiplePurposes_Fails()
+ public async Task CommunityValidators_RunEvenWhenNoExtensionsRequired_AuthorizationCode()
{
+ // Arrange: community validator returns null rules (no extensions required)
+ // but ValidateAsync rejects — validator should still be invoked
var clientStore = Substitute.For();
- clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5")
- .Returns(Task.FromResult(10));
+ clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5"));
- var settings = new ServerSettings
- {
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "urn:oid:2.16.840.1.113883.3.7204.1.5",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- MaxPurposeOfUseCount = 1,
- AllowedPurposeOfUse =
- [
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT",
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PYMNT"
- ]
- }
- ]
- };
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true);
+ communityValidator.GetValidationRules("authorization_code").Returns((CommunityValidationRules?)null);
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Failure(
+ "invalid_grant", "community validator rejected")));
- var validator = CreateValidator(settings, clientStore);
-
- var b2b = new HL7B2BAuthorizationExtension
- {
- OrganizationId = "Organization/1.2.3",
- OrganizationName = "Test QHIN"
- };
- b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT");
- b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-PYMNT");
-
- var extensions = new Dictionary
- {
- [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
- };
- var context = CreateContext(communityId: "10", extensions: extensions);
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "10", grantType: "authorization_code");
+ // Act
var result = await validator.ValidateAsync(context);
+ // Assert: community validator was invoked and its rejection is returned
Assert.False(result.IsValid);
- Assert.Contains("maximum allowed is 1", result.ErrorDescription);
+ Assert.Equal("invalid_grant", result.Error);
+ Assert.Contains("community validator rejected", result.ErrorDescription);
}
[Fact]
- public async Task TefcaStyle_WrongCodeSystem_Fails()
+ public async Task CommunityValidators_RunEvenWhenNoExtensionsRequired_Success()
{
+ // Arrange: community validator returns success
var clientStore = Substitute.For();
- clientStore.GetCommunityId("urn:oid:2.16.840.1.113883.3.7204.1.5")
- .Returns(Task.FromResult(10));
+ clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5"));
- var settings = new ServerSettings
- {
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "urn:oid:2.16.840.1.113883.3.7204.1.5",
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse =
- [
- "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT"
- ]
- }
- ]
- };
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(true);
+ communityValidator.GetValidationRules("authorization_code").Returns((CommunityValidationRules?)null);
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
- var validator = CreateValidator(settings, clientStore);
-
- // Client sends HL7 v3 PurposeOfUse code instead of TEFCA XP code
- var b2b = new HL7B2BAuthorizationExtension
- {
- OrganizationId = "Organization/1.2.3",
- OrganizationName = "Test QHIN"
- };
- b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT");
-
- var extensions = new Dictionary
- {
- [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
- };
- var context = CreateContext(communityId: "10", extensions: extensions);
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "10", grantType: "authorization_code");
+ // Act
var result = await validator.ValidateAsync(context);
- Assert.False(result.IsValid);
- Assert.Contains("disallowed", result.ErrorDescription);
- Assert.Contains("2.16.840.1.113883.5.8#TREAT", result.ErrorDescription);
+ // Assert
+ Assert.True(result.IsValid);
}
[Fact]
- public async Task CommunityOverride_PurposeOfUse_NullAllowList_FailsWithConfigError()
+ public async Task CommunityValidators_NotInvokedForNonMatchingCommunity()
{
var clientStore = Substitute.For();
- clientStore.GetCommunityId("udap://community-a")
- .Returns(Task.FromResult(1));
+ clientStore.GetCommunityName("10").Returns(Task.FromResult("urn:oid:2.16.840.1.113883.3.7204.1.5"));
- var settings = new ServerSettings
- {
- AuthorizationExtensionsRequired = [UdapConstants.UdapAuthorizationExtensions.Hl7B2B],
- AllowedPurposeOfUse = ["urn:oid:2.16.840.1.113883.5.8#ETREAT"],
- CommunitySettings =
- [
- new CommunityServerSettings
- {
- Community = "udap://community-a",
- AllowedPurposeOfUse = null // no fallback to global — must be configured
- }
- ]
- };
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity("urn:oid:2.16.840.1.113883.3.7204.1.5").Returns(false);
- var validator = CreateValidator(settings, clientStore);
- var context = CreateContext(communityId: "1", extensions: CreateValidB2BExtensions());
+ var validator = CreateValidator(clientStore, [communityValidator]);
+ var context = CreateContext(communityId: "10", grantType: "authorization_code");
var result = await validator.ValidateAsync(context);
- Assert.False(result.IsValid);
- Assert.Equal("server_error", result.Error);
- Assert.Contains("AllowedPurposeOfUse is not configured", result.ErrorDescription);
+ Assert.True(result.IsValid);
+ await communityValidator.DidNotReceive().ValidateAsync(Arg.Any());
}
#endregion
@@ -1152,13 +976,43 @@ public async Task CommunityOverride_PurposeOfUse_NullAllowList_FailsWithConfigEr
#region Helpers
private DefaultUdapAuthorizationExtensionValidator CreateValidator(
- ServerSettings settings,
- IUdapClientRegistrationStore? clientStore = null)
+ IUdapClientRegistrationStore? clientStore = null,
+ IEnumerable? communityValidators = null)
{
- var optionsMonitor = new OptionsMonitorForTests(settings);
clientStore ??= Substitute.For();
- return new DefaultUdapAuthorizationExtensionValidator(optionsMonitor, clientStore, _logger);
+ return new DefaultUdapAuthorizationExtensionValidator(
+ clientStore,
+ communityValidators ?? Enumerable.Empty(),
+ _logger);
+ }
+
+ ///
+ /// Creates a community validator mock with a matching client store for community "udap://community-a" (ID "1").
+ ///
+ private static (IUdapClientRegistrationStore clientStore, ICommunityTokenValidator communityValidator) SetupCommunityValidator(
+ HashSet? requiredExtensions = null,
+ HashSet? allowedPurposeOfUse = null,
+ int? maxPurposeOfUseCount = null,
+ string communityName = "udap://community-a",
+ string communityId = "1",
+ string grantType = "client_credentials")
+ {
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName(communityId).Returns(Task.FromResult(communityName));
+
+ var communityValidator = Substitute.For();
+ communityValidator.AppliesToCommunity(communityName).Returns(true);
+ communityValidator.GetValidationRules(grantType).Returns(new CommunityValidationRules
+ {
+ RequiredExtensions = requiredExtensions,
+ AllowedPurposeOfUse = allowedPurposeOfUse,
+ MaxPurposeOfUseCount = maxPurposeOfUseCount
+ });
+ communityValidator.ValidateAsync(Arg.Any())
+ .Returns(Task.FromResult(AuthorizationExtensionValidationResult.Success()));
+
+ return (clientStore, communityValidator);
}
private static UdapAuthorizationExtensionValidationContext CreateContext(
diff --git a/_tests/UdapServer.Tests/Validators/SsraaCommunityValidatorTests.cs b/_tests/UdapServer.Tests/Validators/SsraaCommunityValidatorTests.cs
new file mode 100644
index 000000000..931871f10
--- /dev/null
+++ b/_tests/UdapServer.Tests/Validators/SsraaCommunityValidatorTests.cs
@@ -0,0 +1,280 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using NSubstitute;
+using Udap.Model;
+using Udap.Model.UdapAuthenticationExtensions;
+using Udap.Server.Storage.Stores;
+using Udap.Server.Validation;
+using Udap.Server.Validation.Default;
+using Udap.Ssraa.Server;
+
+namespace UdapServer.Tests.Validators;
+
+public class SsraaCommunityValidatorTests
+{
+ private readonly ILogger _logger =
+ Substitute.For>();
+
+ #region SsraaTokenValidator Unit Tests
+
+ [Fact]
+ public void AppliesToCommunity_ConfiguredCommunity_ReturnsTrue()
+ {
+ var validator = CreateSsraaValidator("udap://fhirlabs.net");
+
+ Assert.True(validator.AppliesToCommunity("udap://fhirlabs.net"));
+ }
+
+ [Fact]
+ public void AppliesToCommunity_UnconfiguredCommunity_ReturnsFalse()
+ {
+ var validator = CreateSsraaValidator("udap://fhirlabs.net");
+
+ Assert.False(validator.AppliesToCommunity("udap://other-community"));
+ }
+
+ [Fact]
+ public void GetValidationRules_ClientCredentials_RequiresHl7B2B()
+ {
+ var validator = CreateSsraaValidator("udap://fhirlabs.net");
+
+ var rules = validator.GetValidationRules("client_credentials");
+
+ Assert.NotNull(rules);
+ Assert.NotNull(rules.RequiredExtensions);
+ Assert.Contains(UdapConstants.UdapAuthorizationExtensions.Hl7B2B, rules.RequiredExtensions);
+ }
+
+ [Fact]
+ public void GetValidationRules_AuthorizationCode_NoExtensionsRequired()
+ {
+ var validator = CreateSsraaValidator("udap://fhirlabs.net");
+
+ var rules = validator.GetValidationRules("authorization_code");
+
+ Assert.NotNull(rules);
+ Assert.Null(rules.RequiredExtensions);
+ }
+
+ [Fact]
+ public void GetValidationRules_AllHl7V3PurposeOfUseCodesPresent()
+ {
+ var validator = CreateSsraaValidator("udap://fhirlabs.net");
+
+ var rules = validator.GetValidationRules("client_credentials");
+
+ Assert.NotNull(rules?.AllowedPurposeOfUse);
+ Assert.Contains("urn:oid:2.16.840.1.113883.5.8#TREAT", rules.AllowedPurposeOfUse);
+ Assert.Contains("urn:oid:2.16.840.1.113883.5.8#ETREAT", rules.AllowedPurposeOfUse);
+ Assert.Contains("urn:oid:2.16.840.1.113883.5.8#BTG", rules.AllowedPurposeOfUse);
+ Assert.Contains("urn:oid:2.16.840.1.113883.5.8#HPAYMT", rules.AllowedPurposeOfUse);
+ Assert.Contains("urn:oid:2.16.840.1.113883.5.8#PUBHLTH", rules.AllowedPurposeOfUse);
+ Assert.Contains("urn:oid:2.16.840.1.113883.5.8#HMARKT", rules.AllowedPurposeOfUse);
+ // Should have all 62 codes
+ Assert.Equal(62, rules.AllowedPurposeOfUse.Count);
+ }
+
+ [Fact]
+ public void GetValidationRules_NoMaxPurposeOfUseCount()
+ {
+ var validator = CreateSsraaValidator("udap://fhirlabs.net");
+
+ var rules = validator.GetValidationRules("client_credentials");
+
+ Assert.NotNull(rules);
+ Assert.Null(rules.MaxPurposeOfUseCount);
+ }
+
+ [Fact]
+ public void GetValidationRules_TefcaXpCode_NotInAllowedSet()
+ {
+ var validator = CreateSsraaValidator("udap://fhirlabs.net");
+
+ var rules = validator.GetValidationRules("client_credentials");
+
+ Assert.NotNull(rules?.AllowedPurposeOfUse);
+ // TEFCA XP codes use a different OID and should NOT be in the SSRAA allowed set
+ Assert.DoesNotContain("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#T-TREAT", rules.AllowedPurposeOfUse);
+ Assert.DoesNotContain("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#Joe", rules.AllowedPurposeOfUse);
+ }
+
+ #endregion
+
+ #region Integration with DefaultUdapAuthorizationExtensionValidator
+
+ [Fact]
+ public async Task SsraaCommunity_TefcaXpCode_IsRejected()
+ {
+ // Reproduces: client_credentials token request to SSRAA community with
+ // purpose_of_use "urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#Joe"
+ // This is a TEFCA OID, not an HL7 v3 PurposeOfUse code, and must be rejected.
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net"));
+
+ var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net");
+ var validator = CreateBaseValidator(clientStore, [ssraaValidator]);
+
+ var b2b = new HL7B2BAuthorizationExtension
+ {
+ OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99",
+ OrganizationName = "FhirLabs"
+ };
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.3.7204.1.5.2.1#Joe");
+
+ var extensions = new Dictionary
+ {
+ [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
+ };
+ var context = CreateContext(communityId: "1", extensions: extensions);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("invalid_grant", result.Error);
+ Assert.Contains("disallowed", result.ErrorDescription);
+ Assert.Contains("Joe", result.ErrorDescription);
+ }
+
+ [Fact]
+ public async Task SsraaCommunity_ValidHl7V3Code_Succeeds()
+ {
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net"));
+
+ var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net");
+ var validator = CreateBaseValidator(clientStore, [ssraaValidator]);
+
+ var b2b = new HL7B2BAuthorizationExtension
+ {
+ OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99",
+ OrganizationName = "FhirLabs"
+ };
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT");
+
+ var extensions = new Dictionary
+ {
+ [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
+ };
+ var context = CreateContext(communityId: "1", extensions: extensions);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public async Task SsraaCommunity_BogusCode_IsRejected()
+ {
+ // Any arbitrary string that isn't in the HL7 v3 value set should be rejected
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net"));
+
+ var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net");
+ var validator = CreateBaseValidator(clientStore, [ssraaValidator]);
+
+ var b2b = new HL7B2BAuthorizationExtension
+ {
+ OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99",
+ OrganizationName = "FhirLabs"
+ };
+ b2b.PurposeOfUse!.Add("TOTALLY-MADE-UP");
+
+ var extensions = new Dictionary
+ {
+ [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
+ };
+ var context = CreateContext(communityId: "1", extensions: extensions);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("invalid_grant", result.Error);
+ Assert.Contains("disallowed", result.ErrorDescription);
+ }
+
+ [Fact]
+ public async Task SsraaCommunity_MultipleValidCodes_Succeeds()
+ {
+ // SSRAA does not limit purpose_of_use count (MaxPurposeOfUseCount is null)
+ var clientStore = Substitute.For();
+ clientStore.GetCommunityName("1").Returns(Task.FromResult("udap://fhirlabs.net"));
+
+ var ssraaValidator = CreateSsraaValidator("udap://fhirlabs.net");
+ var validator = CreateBaseValidator(clientStore, [ssraaValidator]);
+
+ var b2b = new HL7B2BAuthorizationExtension
+ {
+ OrganizationId = "https://fhirlabs.net/fhir/r4/Organization/99",
+ OrganizationName = "FhirLabs"
+ };
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#TREAT");
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#ETREAT");
+ b2b.PurposeOfUse!.Add("urn:oid:2.16.840.1.113883.5.8#HPAYMT");
+
+ var extensions = new Dictionary
+ {
+ [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
+ };
+ var context = CreateContext(communityId: "1", extensions: extensions);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static SsraaTokenValidator CreateSsraaValidator(params string[] communities)
+ {
+ var options = new SsraaValidationOptions();
+ foreach (var c in communities) options.Communities.Add(c);
+ return new SsraaTokenValidator(Options.Create(options));
+ }
+
+ private DefaultUdapAuthorizationExtensionValidator CreateBaseValidator(
+ IUdapClientRegistrationStore clientStore,
+ IEnumerable communityValidators)
+ {
+ return new DefaultUdapAuthorizationExtensionValidator(
+ clientStore, communityValidators, _logger);
+ }
+
+ private static UdapAuthorizationExtensionValidationContext CreateContext(
+ string? communityId = null,
+ Dictionary? extensions = null,
+ string clientId = "test-client",
+ string grantType = "client_credentials")
+ {
+ var handler = new JsonWebTokenHandler();
+ var jwt = handler.ReadJsonWebToken(
+ handler.CreateToken(new SecurityTokenDescriptor
+ {
+ Issuer = clientId,
+ Claims = new Dictionary { ["sub"] = clientId }
+ }));
+
+ return new UdapAuthorizationExtensionValidationContext
+ {
+ ClientAssertionToken = jwt,
+ ClientId = clientId,
+ Extensions = extensions,
+ CommunityId = communityId,
+ GrantType = grantType
+ };
+ }
+
+ #endregion
+}
diff --git a/_tests/UdapServer.Tests/Validators/TefcaCommunityValidatorTests.cs b/_tests/UdapServer.Tests/Validators/TefcaCommunityValidatorTests.cs
new file mode 100644
index 000000000..71419f50f
--- /dev/null
+++ b/_tests/UdapServer.Tests/Validators/TefcaCommunityValidatorTests.cs
@@ -0,0 +1,359 @@
+#region (c) 2026 Joseph Shook. All rights reserved.
+// /*
+// Authors:
+// Joseph Shook Joseph.Shook@Surescripts.com
+//
+// See LICENSE in the project root for license information.
+// */
+#endregion
+
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using Udap.Model;
+using Udap.Model.Registration;
+using Udap.Model.UdapAuthenticationExtensions;
+using Udap.Server.Registration;
+using Udap.Server.Validation;
+using Udap.Tefca.Model;
+using Udap.Tefca.Server;
+using Xunit;
+
+namespace UdapServer.Tests.Validators;
+
+public class TefcaCommunityValidatorTests
+{
+ private static readonly IOptions DefaultOptions =
+ Options.Create(new TefcaValidationOptions());
+ #region TefcaRegistrationValidator
+
+ [Theory]
+ [InlineData("T-TRTMNT")]
+ [InlineData("T-TREAT")]
+ [InlineData("T-PYMNT")]
+ [InlineData("T-HCO")]
+ [InlineData("T-HCO-CC")]
+ [InlineData("T-HCO-HED")]
+ [InlineData("T-HCO-QM")]
+ [InlineData("T-PH")]
+ [InlineData("T-PH-ECR")]
+ [InlineData("T-PH-ELR")]
+ [InlineData("T-IAS")]
+ [InlineData("T-GOVDTRM")]
+ public async Task Registration_ValidExchangePurpose_Succeeds(string xpCode)
+ {
+ var validator = new TefcaRegistrationValidator(DefaultOptions);
+ var context = CreateRegistrationContext($"urn:oid:2.999#{xpCode}");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.Null(result); // null = success
+ }
+
+ [Fact]
+ public async Task Registration_InvalidExchangePurpose_IsRejected()
+ {
+ var validator = new TefcaRegistrationValidator(DefaultOptions);
+ var context = CreateRegistrationContext("urn:oid:2.999#INVALID");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.NotNull(result);
+ Assert.True(result.IsError);
+ Assert.Contains("INVALID", result.ErrorDescription);
+ }
+
+ [Fact]
+ public async Task Registration_SanUriWithoutFragment_IsRejected()
+ {
+ var validator = new TefcaRegistrationValidator(DefaultOptions);
+ var context = CreateRegistrationContext("urn:oid:2.999");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.NotNull(result);
+ Assert.True(result.IsError);
+ Assert.Contains("exchange purpose code after '#'", result.ErrorDescription);
+ }
+
+ [Fact]
+ public async Task Registration_EmptyIssuer_IsRejected()
+ {
+ var validator = new TefcaRegistrationValidator(DefaultOptions);
+ var context = CreateRegistrationContext(null);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.NotNull(result);
+ Assert.True(result.IsError);
+ }
+
+ [Fact]
+ public async Task Registration_TrailingHash_IsRejected()
+ {
+ var validator = new TefcaRegistrationValidator(DefaultOptions);
+ var context = CreateRegistrationContext("urn:oid:2.999#");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.NotNull(result);
+ Assert.True(result.IsError);
+ }
+
+ [Fact]
+ public void Registration_AppliesToTefcaCommunity()
+ {
+ var validator = new TefcaRegistrationValidator(DefaultOptions);
+
+ Assert.True(validator.AppliesToCommunity(TefcaConstants.CommunityUri));
+ Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/"));
+ Assert.False(validator.AppliesToCommunity("udap://Provider2"));
+ }
+
+ #endregion
+
+ #region TefcaTokenValidator
+
+ [Fact]
+ public async Task Token_MatchingPurposeOfUse_Succeeds()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-TREAT",
+ purposeOfUse: $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#T-TREAT");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public async Task Token_MatchingPurposeOfUse_BareCode_Succeeds()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-TRTMNT",
+ purposeOfUse: "T-TRTMNT");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public async Task Token_MismatchedPurposeOfUse_IsRejected()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-TREAT",
+ purposeOfUse: $"urn:oid:{TefcaConstants.ExchangePurposeCodes.Oid}#T-PYMNT");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("invalid_grant", result.Error);
+ Assert.Contains("T-PYMNT", result.ErrorDescription);
+ Assert.Contains("T-TREAT", result.ErrorDescription);
+ }
+
+ [Fact]
+ public async Task Token_NoRegisteredSanUri_IsRejected()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: null,
+ purposeOfUse: "T-TREAT");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("invalid_grant", result.Error);
+ }
+
+ [Fact]
+ public async Task Token_SanUriWithoutFragment_IsRejected()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999",
+ purposeOfUse: "T-TREAT");
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public async Task Token_NoExtensions_Succeeds()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-TREAT",
+ purposeOfUse: null); // no extensions at all
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public void Token_AppliesToTefcaCommunity()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+
+ Assert.True(validator.AppliesToCommunity(TefcaConstants.CommunityUri));
+ Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/"));
+ }
+
+ #endregion
+
+ #region Regression — non-TEFCA communities
+
+ [Fact]
+ public void Registration_NonTefcaCommunity_NotApplicable()
+ {
+ var validator = new TefcaRegistrationValidator(DefaultOptions);
+
+ // Validator should not apply, so it would never be called
+ Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/"));
+ }
+
+ [Fact]
+ public void Token_NonTefcaCommunity_NotApplicable()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+
+ Assert.False(validator.AppliesToCommunity("udap://fhirlabs1/"));
+ }
+
+ #endregion
+
+ #region TefcaIAS conditional requirement
+
+ [Fact]
+ public async Task Token_IAS_ClientCredentials_WithTefcaIas_Succeeds()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-IAS",
+ purposeOfUse: "T-IAS",
+ grantType: "client_credentials",
+ includeTefcaIas: true);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public async Task Token_IAS_ClientCredentials_WithoutTefcaIas_IsRejected()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-IAS",
+ purposeOfUse: "T-IAS",
+ grantType: "client_credentials",
+ includeTefcaIas: false);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("invalid_grant", result.Error);
+ Assert.Contains("tefca_ias", result.ErrorDescription);
+ }
+
+ [Fact]
+ public async Task Token_IAS_AuthorizationCode_WithoutTefcaIas_Succeeds()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-IAS",
+ purposeOfUse: "T-IAS",
+ grantType: "authorization_code",
+ includeTefcaIas: false);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public async Task Token_NonIAS_ClientCredentials_WithoutTefcaIas_Succeeds()
+ {
+ var validator = new TefcaTokenValidator(DefaultOptions);
+ var context = CreateTokenContext(
+ sanUri: "urn:oid:2.999#T-TREAT",
+ purposeOfUse: "T-TREAT",
+ grantType: "client_credentials",
+ includeTefcaIas: false);
+
+ var result = await validator.ValidateAsync(context);
+
+ Assert.True(result.IsValid);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static UdapDynamicClientRegistrationContext CreateRegistrationContext(string? issuer)
+ {
+ return new UdapDynamicClientRegistrationContext
+ {
+ Request = new UdapRegisterRequest(),
+ Issuer = issuer,
+ CommunityName = TefcaConstants.CommunityUri
+ };
+ }
+
+ private static UdapAuthorizationExtensionValidationContext CreateTokenContext(
+ string? sanUri,
+ string? purposeOfUse,
+ string grantType = "client_credentials",
+ bool includeTefcaIas = false)
+ {
+ var handler = new JsonWebTokenHandler();
+ var jwt = handler.ReadJsonWebToken(
+ handler.CreateToken(new SecurityTokenDescriptor
+ {
+ Issuer = "test-client",
+ Claims = new Dictionary { ["sub"] = "test-client" }
+ }));
+
+ Dictionary? extensions = null;
+
+ if (purposeOfUse != null)
+ {
+ var b2b = new HL7B2BAuthorizationExtension
+ {
+ OrganizationId = "Organization/1.2.3",
+ OrganizationName = "Test Org"
+ };
+ b2b.PurposeOfUse!.Add(purposeOfUse);
+
+ extensions = new Dictionary
+ {
+ [UdapConstants.UdapAuthorizationExtensions.Hl7B2B] = b2b
+ };
+
+ if (includeTefcaIas)
+ {
+ extensions[TefcaConstants.UdapAuthorizationExtensions.TEFCAIAS] =
+ new TEFCAIASAuthorizationExtension();
+ }
+ }
+
+ return new UdapAuthorizationExtensionValidationContext
+ {
+ ClientAssertionToken = jwt,
+ ClientId = "test-client",
+ Extensions = extensions,
+ CommunityId = "1",
+ CommunityName = TefcaConstants.CommunityUri,
+ GrantType = grantType,
+ SanUri = sanUri
+ };
+ }
+
+ #endregion
+}
diff --git a/docs/migration-to-openiddict.md b/docs/migration-to-openiddict.md
new file mode 100644
index 000000000..41920d150
--- /dev/null
+++ b/docs/migration-to-openiddict.md
@@ -0,0 +1,284 @@
+# Migration Plan: Duende IdentityServer → OpenIddict
+
+## Overview
+
+This document captures the analysis and plan for migrating udap-dotnet from Duende IdentityServer to OpenIddict as the underlying OAuth 2.0 / OpenID Connect server framework.
+
+**Motivation**: OpenIddict is Apache 2.0 licensed with no revenue restrictions. Duende requires a paid license for companies over $1M annual revenue.
+
+**Note**: `Duende.IdentityModel.Client` is a free/open-source component and does not need to be replaced.
+
+---
+
+## Integration Depth Summary
+
+- **177 files** (32% of codebase) reference Duende namespaces
+- **462 `using Duende` statements** across the project
+- Coupling is architectural — Duende provides the core OAuth2/OIDC pipeline
+
+### Duende NuGet Packages Currently Used (v7.4.6)
+
+| Project | Duende Packages |
+|---|---|
+| Udap.Server | `Duende.IdentityServer`, `Duende.IdentityServer.AspNetIdentity`, `Duende.IdentityServer.EntityFramework.Storage` |
+| Udap.Server.Storage | `Duende.IdentityServer.EntityFramework.Storage`, `Duende.IdentityServer.Storage` |
+| Udap.Auth.Server (example) | `Duende.IdentityServer`, `Duende.IdentityServer.EntityFramework` |
+| Udap.Identity.Provider (examples) | `Duende.IdentityServer`, `Duende.IdentityServer.EntityFramework` |
+| UdapDb.SqlServer / Postgres (migrations) | `Duende.IdentityServer.EntityFramework.Storage` |
+| Udap.UI | `Duende.IdentityServer` |
+
+---
+
+## Paradigm Difference: Interface vs Handler Pipeline
+
+### Duende: Interface Implementation
+
+Implement a well-known interface, register in DI.
+
+```csharp
+// Duende pattern
+public class UdapJwtSecretValidator : ISecretValidator
+{
+ public UdapJwtSecretValidator(
+ IIssuerNameService issuerNameService,
+ IReplayCache replayCache,
+ IServerUrls urls,
+ IdentityServerOptions options,
+ TrustChainValidator trustChainValidator, ...)
+
+ public Task ValidateAsync(
+ IEnumerable secrets, ParsedSecret parsedSecret)
+ {
+ // return success/failure result
+ }
+}
+
+// Registration
+services.AddTransient();
+```
+
+### OpenIddict: Event Handler Pipeline
+
+Implement a handler with a static Descriptor controlling ordering/filtering, communicate via context mutation.
+
+```csharp
+// OpenIddict pattern
+public class UdapJwtSecretHandler : IOpenIddictServerHandler
+{
+ public UdapJwtSecretHandler(
+ TrustChainValidator trustChainValidator, ...)
+
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .UseScopedHandler()
+ .SetOrder(ValidateAuthentication.Descriptor.Order + 500)
+ .AddFilter()
+ .Build();
+
+ public ValueTask HandleAsync(ValidateTokenRequestContext context)
+ {
+ // Failure: context.Reject(error, description, uri)
+ // Success: just return (or set context properties)
+ // Full stop: context.HandleRequest()
+ }
+}
+
+// Registration
+services.AddOpenIddict()
+ .AddServer(options =>
+ {
+ options.AddEventHandler(UdapJwtSecretHandler.Descriptor);
+ });
+```
+
+### Key Differences
+
+| Aspect | Duende (Interface) | OpenIddict (Handler Pipeline) |
+|---|---|---|
+| **Contract** | Specific interfaces (`ISecretValidator`, `ISecretParser`, etc.) with typed return values | Always `IOpenIddictServerHandler` with `ValueTask HandleAsync(TContext)` |
+| **Result** | Return a result object (`SecretValidationResult`) | Mutate the context (`context.Reject(...)` or set properties) |
+| **Ordering** | Duende controls when your implementation is called | You control ordering via `SetOrder(int)` |
+| **Composition** | One `ISecretValidator` replaces the default | Multiple handlers run in sequence — add alongside built-ins |
+| **Scope** | Each interface has a narrow, specific purpose | One pattern fits all — same `HandleAsync` for auth, tokens, discovery, etc. |
+
+---
+
+## Components to Migrate
+
+### Unaffected (No Duende Dependency)
+
+- **Udap.Model** — data models, zero external dependencies
+- **Udap.Common** — certificate/trust chain validation, independent of auth server
+- **Udap.Metadata.Server** — minimal Duende coupling
+
+### Minimal Changes
+
+- **Udap.Client** — uses `Duende.IdentityModel.Client` which is free/open-source. No migration needed for this dependency.
+
+### Major Migration Required
+
+#### 1. Udap.Server — Custom Duende Interface Implementations
+
+| Component | Duende Interface | Migration Notes |
+|---|---|---|
+| `UdapJwtBearerClientAssertionSecretParser` | `ISecretParser` | Rewrite as OpenIddict handler for `ExtractTokenRequestContext` or `ProcessAuthenticationContext` |
+| `UdapJwtSecretValidator` | `ISecretValidator` | Rewrite as OpenIddict handler for `ValidateTokenRequestContext` |
+| `UdapCustomTokenRequestValidator` | `ICustomTokenRequestValidator` | Rewrite as OpenIddict handler — hl7-b2b/TEFCA extension enforcement |
+| `UdapTokenResponseGenerator` | Extends `TokenResponseGenerator` | No base class equivalent in OpenIddict — rewrite as `HandleTokenRequestContext` handler |
+| `UdapInMemoryResourceStore` | `IResourceStore` | Replace with `IOpenIddictScopeManager` — scope expansion logic stays |
+| `UdapDiscoveryEndpoint` | Registered as Duende `Endpoint` | Rewrite as ASP.NET Core middleware or controller |
+
+#### 2. Udap.Server — Middleware (Duende Service Dependencies)
+
+| Component | Duende Dependencies | Migration Notes |
+|---|---|---|
+| `UdapAuthorizationResponseMiddleware` | `IClientStore`, `IIdentityServerInteractionService` | Replace with `IOpenIddictApplicationManager` + ASP.NET Core equivalents |
+| `UdapScopeEnrichmentMiddleware` | Duende scope handling | Adapt to OpenIddict scope model |
+| `UdapTokenResponseMiddleware` | Duende token endpoint | Adapt to OpenIddict token pipeline |
+
+#### 3. Udap.Server — Dynamic Client Registration (DCR)
+
+The DCR is custom-built (not Duende's DCR). Key dependencies to replace:
+
+| Dependency | Duende Source | OpenIddict Equivalent |
+|---|---|---|
+| `IResourceStore` | Duende | `IOpenIddictScopeManager` |
+| `IReplayCache` | Duende | Custom implementation or use OpenIddict token storage |
+| `IScopeExpander` | Custom (uses `IResourceStore`) | Refactor to use `IOpenIddictScopeManager` |
+
+Core registration logic (JWT validation, certificate chain validation, UDAP spec compliance) is **your own code** and transfers directly.
+
+#### 4. Udap.Server — Tiered OAuth
+
+| Component | Duende Dependencies | Migration Notes |
+|---|---|---|
+| `TieredOAuthHelpers` | `IIdentityServerInteractionService`, `IUdapClient` | Replace interaction service with OpenIddict equivalents |
+| `TieredOAuthAuthenticationHandler` | Duende external auth integration | Adapt to OpenIddict's external auth model |
+
+#### 5. Udap.Server — DI/Configuration
+
+| Component | Notes |
+|---|---|
+| `UdapServerServiceCollectionExtensions` | Rewrite `AddUdapServer()` to use `services.AddOpenIddict()` builder pattern |
+| `UdapBuilderExtensions/UdapCore` | `AddUdapDiscovery()`, `AddUdapResponseGenerators()`, etc. — rewrite for OpenIddict |
+| `Additional.cs` | `AddUdapJwtBearerClientAuthentication()` — register OpenIddict handlers instead of Duende interfaces |
+
+#### 6. Udap.Server.Storage — EF Core / Data Layer
+
+**Schema change**: Duende uses `ConfigurationDbContext` + `PersistedGrantDbContext` with Client/Scope/Grant entities. OpenIddict uses 4 tables:
+
+| OpenIddict Table | Purpose |
+|---|---|
+| `OpenIddictApplications` | Client registration (replaces Duende `Client` entity) |
+| `OpenIddictScopes` | Scope definitions |
+| `OpenIddictAuthorizations` | Authorization grants |
+| `OpenIddictTokens` | Token storage (access, refresh, etc.) |
+
+Key changes:
+- `UdapClientRegistrationStore` — currently maps to/from `Duende.IdentityServer.EntityFramework.Entities.Client`. Must be rewritten to use `IOpenIddictApplicationManager`.
+- `UdapDbContext` — currently includes `DbSet`, `DbSet`, `DbSet`. Replace with OpenIddict entity sets.
+- All mappers (`ClientExtensions.cs`) need rewriting for OpenIddict model.
+- UDAP-specific entities (Anchor, Intermediate, Community, Certification, TieredClient) are **unaffected** — these are your own tables.
+
+#### 7. Database Migrations
+
+Both `UdapDb.SqlServer` and `UdapDb.Postgres` need new migrations to:
+- Drop Duende tables (Client, ClientSecret, ClientScope, ApiResource, ApiScope, PersistedGrant, DeviceFlowCodes, etc.)
+- Add OpenIddict tables (Applications, Scopes, Authorizations, Tokens)
+- Preserve UDAP-specific tables
+
+#### 8. Example Projects
+
+All example auth servers need their startup rewritten:
+
+| Project | Current Pattern | New Pattern |
+|---|---|---|
+| `Udap.Auth.Server` | `AddIdentityServer()` + `AddUdapServer()` | `AddOpenIddict()` + `AddUdapServer()` (refactored) |
+| `Udap.Identity.Provider` | `AddIdentityServer()` + `AddUdapServerAsIdentityProvider()` | `AddOpenIddict()` + equivalent |
+| `Udap.Identity.Provider.2` | Same as above | Same as above |
+
+#### 9. Test Infrastructure
+
+- `UdapAuthServerPipeline` is built around Duende's test infrastructure — needs rewriting
+- Integration tests that configure `IdentityServerOptions` need updating
+- Test projects referencing `Duende.IdentityServer.Test` need alternatives
+
+#### 10. Udap.UI
+
+- References `Duende.IdentityServer` for login/logout/consent flows
+- Needs adaptation for OpenIddict's approach to user interaction
+
+---
+
+## Duende Service → OpenIddict Equivalent Mapping
+
+| Duende Service | OpenIddict Equivalent |
+|---|---|
+| `IClientStore` | `IOpenIddictApplicationManager` |
+| `IResourceStore` | `IOpenIddictScopeManager` |
+| `ISecretParser` | `IOpenIddictServerHandler