wsfed-caep 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wsfed_caep-0.1.0/LICENSE +21 -0
- wsfed_caep-0.1.0/PKG-INFO +100 -0
- wsfed_caep-0.1.0/README.md +81 -0
- wsfed_caep-0.1.0/pyproject.toml +36 -0
- wsfed_caep-0.1.0/setup.cfg +4 -0
- wsfed_caep-0.1.0/src/wsfed_caep/__init__.py +58 -0
- wsfed_caep-0.1.0/src/wsfed_caep/cli.py +149 -0
- wsfed_caep-0.1.0/src/wsfed_caep/config.py +153 -0
- wsfed_caep-0.1.0/src/wsfed_caep/encryption.py +128 -0
- wsfed_caep-0.1.0/src/wsfed_caep/errors.py +13 -0
- wsfed_caep-0.1.0/src/wsfed_caep/http_client.py +82 -0
- wsfed_caep-0.1.0/src/wsfed_caep/metadata.py +153 -0
- wsfed_caep-0.1.0/src/wsfed_caep/relying_party.py +195 -0
- wsfed_caep-0.1.0/src/wsfed_caep/replay.py +30 -0
- wsfed_caep-0.1.0/src/wsfed_caep/signature.py +157 -0
- wsfed_caep-0.1.0/src/wsfed_caep/ssf/__init__.py +27 -0
- wsfed_caep-0.1.0/src/wsfed_caep/ssf/events.py +103 -0
- wsfed_caep-0.1.0/src/wsfed_caep/ssf/jws.py +116 -0
- wsfed_caep-0.1.0/src/wsfed_caep/ssf/receiver.py +310 -0
- wsfed_caep-0.1.0/src/wsfed_caep/ssf/session_index.py +78 -0
- wsfed_caep-0.1.0/src/wsfed_caep/token.py +293 -0
- wsfed_caep-0.1.0/src/wsfed_caep/x509util.py +144 -0
- wsfed_caep-0.1.0/src/wsfed_caep/xmlutil.py +172 -0
- wsfed_caep-0.1.0/src/wsfed_caep.egg-info/PKG-INFO +100 -0
- wsfed_caep-0.1.0/src/wsfed_caep.egg-info/SOURCES.txt +33 -0
- wsfed_caep-0.1.0/src/wsfed_caep.egg-info/dependency_links.txt +1 -0
- wsfed_caep-0.1.0/src/wsfed_caep.egg-info/entry_points.txt +2 -0
- wsfed_caep-0.1.0/src/wsfed_caep.egg-info/requires.txt +5 -0
- wsfed_caep-0.1.0/src/wsfed_caep.egg-info/top_level.txt +1 -0
- wsfed_caep-0.1.0/tests/test_configure.py +144 -0
- wsfed_caep-0.1.0/tests/test_http.py +114 -0
- wsfed_caep-0.1.0/tests/test_rp.py +345 -0
- wsfed_caep-0.1.0/tests/test_ssf.py +323 -0
- wsfed_caep-0.1.0/tests/test_trust_chain.py +295 -0
- wsfed_caep-0.1.0/tests/test_x509.py +67 -0
wsfed_caep-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Iya CyberSecurity Solutions, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wsfed-caep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side WS-Federation relying party that accepts SAML 2.0 assertions, and a CAEP and RISC receiver.
|
|
5
|
+
Author: Iya CyberSecurity Solutions, LLC
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rcbj/wsfed-caep-python
|
|
8
|
+
Project-URL: Repository, https://github.com/rcbj/wsfed-caep-python
|
|
9
|
+
Project-URL: Issues, https://github.com/rcbj/wsfed-caep-python/issues
|
|
10
|
+
Keywords: ws-federation,wsfed,saml2,caep,risc,shared-signals
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: cryptography>=42
|
|
15
|
+
Requires-Dist: lxml>=5
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
<!--
|
|
21
|
+
SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
|
|
22
|
+
SPDX-License-Identifier: MIT
|
|
23
|
+
-->
|
|
24
|
+
|
|
25
|
+
# wsfed-caep
|
|
26
|
+
|
|
27
|
+
Server-side WS-Federation relying party for Python. It accepts SAML 2.0 bearer assertions, reads identity-provider federation metadata, writes relying-party metadata, verifies XML signatures, decrypts encrypted assertions, and remembers assertion ids so a bearer token cannot be replayed. A Shared Signals poll receiver for CAEP and RISC is included. The relying party does not start that receiver.
|
|
28
|
+
|
|
29
|
+
Version 0.1.0. License: MIT. A pull request merged into `main` publishes the package to PyPI after the test workflow succeeds on that commit. A direct push does not. There is no API token: PyPI Trusted Publishing trades the workflow's GitHub OIDC token for a short-lived upload token, from the `pypi` environment, and PyPI holds the publisher for this repository and `publish.yml`. A version already on PyPI stays there; the next release is a new `version` in `pyproject.toml` (and `VERSION`). `develop` and `main` are the same commit when this tree is published; nothing here creates a remote.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from wsfed_caep import RelyingParty
|
|
35
|
+
|
|
36
|
+
rp = RelyingParty.from_file("wsfed.json")
|
|
37
|
+
request = rp.login(relay_state="/")
|
|
38
|
+
who = rp.handle_reply({"method": "POST", "url": "/wsfed", "body": body}, request.id)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
JSON keys match the Node configuration: `realm`, `replyUrl`, `idpMetadata`, `privateKey`, `certificate`, `idpCa`, `idpEntityId`, `clockSkewSeconds`, `wantAssertionsEncrypted`, `allowUnsolicited`, `requestTokenType`, `signals`, `ssf`. `file:` and `env:` prefixes are accepted. The default subject attribute is `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier`.
|
|
42
|
+
|
|
43
|
+
`signals` omitted or `true` is on. The resolved configuration omits the key in that case. `false` is kept, does not start a receiver, and does not require transmitter metadata. Sign-in is unchanged either way: WS-Federation does not send OAuth scopes. Any other value raises a config error whose message is `signals must be true or false`.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
docker compose build
|
|
47
|
+
docker run --rm --user "$(id -u):$(id -g)" \
|
|
48
|
+
-v "$PWD/demo:/app/demo" \
|
|
49
|
+
wsfed-caep-python-demo \
|
|
50
|
+
wsfed-caep generate-keys --config demo/wsfed.json
|
|
51
|
+
docker run --rm --user "$(id -u):$(id -g)" \
|
|
52
|
+
-v "$PWD/demo:/app/demo" \
|
|
53
|
+
wsfed-caep-python-demo \
|
|
54
|
+
wsfed-caep metadata --config demo/wsfed.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The generated certificate's common name defaults to `wsfed-caep-nodejs relying party` plus the realm, cut at 64 characters.
|
|
58
|
+
|
|
59
|
+
## Tests
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
docker build --target test .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The suite runs in a container.
|
|
66
|
+
|
|
67
|
+
The suite includes a stub identity provider. Those tests do not start iya-sts.
|
|
68
|
+
|
|
69
|
+
`python3 scripts/live_iya_sts.py` is the live half. It uses iya-sts (the latest main branch, in memory mode with persistence and clustering off, when `IYA_STS_URL` is unset; `IYA_STS_IMAGE` starts a fresh container from an image), copies this checkout to a scratch build context, builds the demo image, runs the install script inside it (which registers the demo, generates the key pair and writes the `idpCa` chain), then builds the demo image again so it carries the chain, checks the image holds the chain and no key, starts the demo with no CA mount, signs in through `GET /login`, and requires a credential-change event and a session-revoked event on `GET /live-status`. The second event ends the local session. It uses the host's Python and Docker and does not import this library. It uses two high ports (defaults 18556 and 18656, `LIVE_STS_PORT` and `LIVE_DEMO_PORT`) and never binds 3000, 8081 or 18481-18495. No secret appears on a docker command line, and sign-in requests are logged by fixed labels, not URLs. The nightly workflow (06:35 UTC) runs the unit suite and this script once every 24 hours.
|
|
70
|
+
|
|
71
|
+
## Layout
|
|
72
|
+
|
|
73
|
+
- `src/wsfed_caep/` library
|
|
74
|
+
- `tests/` pytest, fixtures, stub identity provider
|
|
75
|
+
- `demo/app.py` stdlib HTTP demo
|
|
76
|
+
- `scripts/configure_iya_sts.py` registers the demo with iya-sts and writes the listener CA trust chain to the file `idpCa` names
|
|
77
|
+
- `scripts/trust_chain.py` holds `save_trust_chain()` and refreshes that file
|
|
78
|
+
- `scripts/live_iya_sts.py` live sign-in against iya-sts
|
|
79
|
+
- `.github/workflows/nightly.yml` unit suite and live script, once every 24 hours
|
|
80
|
+
- `Dockerfile`, `docker-compose.yml` (`network_mode: host`, `PORT=3000`)
|
|
81
|
+
|
|
82
|
+
## The iya-sts trust chain (`idpCa`)
|
|
83
|
+
|
|
84
|
+
`idpCa` in `demo/wsfed.json` names the iya-sts TLS listener CA trust chain the library trusts for the IdP metadata URL and the Shared Signals transmitter. The default is `file:generated/idp-ca.pem`, relative to `demo/`. The value may also be a plain path, `env:<NAME>`, or the PEM itself. The library loads every certificate in it as a trust anchor (`ssl` `load_verify_locations(cadata=...)`), so the whole chain works and OpenSSL ends the path at the self-signed Root. The presented leaf must still verify to one of those anchors (a forged leaf followed by the real CA certificates is refused), and the host name is checked against the leaf's subject alternative names (a DNS name, or an IP literal such as `127.0.0.1`).
|
|
85
|
+
|
|
86
|
+
`scripts/configure_iya_sts.py`, the install script, writes that file on every run (`scripts/trust_chain.py`, `save_trust_chain()`). It saves the whole chain iya-sts publishes at `GET /tls/server-certificate` (leaf, TLS Issuing CA, Intermediate, Root), or the chain from the TLS handshake when that path does not answer. It prints the path, the trust anchor's subject and its SHA-256 fingerprint. Compare the anchor with the Root CA on iya-sts's PKI page. A password iya-sts generates for the test user is never printed: it goes to `demo/generated/<user>.password`, mode 0600.
|
|
87
|
+
|
|
88
|
+
The demo image build copies that file into the image at `/app/demo/generated/idp-ca.pem`, so the image trusts that iya-sts with no bind mount and no fetch. Run the install script before you build the image, and rebuild the image after running the install script against a different iya-sts. `.dockerignore` admits only that file from `demo/generated`: the relying party's key, certificate and the IdP metadata stay out of the image (compose still mounts `demo/generated` read-only for them). A build with no chain file yet (a fresh clone, CI) still succeeds; the image then has no `idpCa` file.
|
|
89
|
+
|
|
90
|
+
The mechanism is `COPY demo ./demo` plus `.dockerignore`'s `demo/generated/*` and `!demo/generated/idp-ca.pem`. Do not replace it with a COPY that names the file or globs for it: when the file is absent, both BuildKit and the classic builder fail. A directory COPY of `demo/` (which always exists) succeeds in both. `python scripts/trust_chain.py --config demo/wsfed.json --idp <iya-sts URL>` refreshes the file by hand.
|
|
91
|
+
|
|
92
|
+
## Demo
|
|
93
|
+
|
|
94
|
+
`PORT` defaults to 3000. A loopback realm and reply URL follow that port. Any other host stays as written. `PUBLIC_URL` or `--public-url` replaces the origin and keeps the path. The default `http://localhost:3000` is not an explicit public URL. `GET /healthz` returns `200` and the body `ok` even when generated keys are absent. `GET /login` redirects to the identity provider with `wa=wsignin1.0`, `wtrealm`, `wreply`, and `wctx`. The reply URL accepts the sign-in POST and `wsignoutcleanup1.0`. `POST /logout` signs out, including at the identity provider. `GET /live-status` is JSON: `signedIn`, `subject`, the receiver state, and the Shared Signals events this process has accepted (`type` and `jti` only). No token is in that body.
|
|
95
|
+
|
|
96
|
+
Demo JSON uses `file:` paths under `demo/generated/`. Those keys are not in the tree. `"signals"` is omitted, so the receiver is on when a transmitter is configured.
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
PORT=40123 docker compose up --build
|
|
100
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
|
|
3
|
+
SPDX-License-Identifier: MIT
|
|
4
|
+
-->
|
|
5
|
+
|
|
6
|
+
# wsfed-caep
|
|
7
|
+
|
|
8
|
+
Server-side WS-Federation relying party for Python. It accepts SAML 2.0 bearer assertions, reads identity-provider federation metadata, writes relying-party metadata, verifies XML signatures, decrypts encrypted assertions, and remembers assertion ids so a bearer token cannot be replayed. A Shared Signals poll receiver for CAEP and RISC is included. The relying party does not start that receiver.
|
|
9
|
+
|
|
10
|
+
Version 0.1.0. License: MIT. A pull request merged into `main` publishes the package to PyPI after the test workflow succeeds on that commit. A direct push does not. There is no API token: PyPI Trusted Publishing trades the workflow's GitHub OIDC token for a short-lived upload token, from the `pypi` environment, and PyPI holds the publisher for this repository and `publish.yml`. A version already on PyPI stays there; the next release is a new `version` in `pyproject.toml` (and `VERSION`). `develop` and `main` are the same commit when this tree is published; nothing here creates a remote.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from wsfed_caep import RelyingParty
|
|
16
|
+
|
|
17
|
+
rp = RelyingParty.from_file("wsfed.json")
|
|
18
|
+
request = rp.login(relay_state="/")
|
|
19
|
+
who = rp.handle_reply({"method": "POST", "url": "/wsfed", "body": body}, request.id)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
JSON keys match the Node configuration: `realm`, `replyUrl`, `idpMetadata`, `privateKey`, `certificate`, `idpCa`, `idpEntityId`, `clockSkewSeconds`, `wantAssertionsEncrypted`, `allowUnsolicited`, `requestTokenType`, `signals`, `ssf`. `file:` and `env:` prefixes are accepted. The default subject attribute is `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier`.
|
|
23
|
+
|
|
24
|
+
`signals` omitted or `true` is on. The resolved configuration omits the key in that case. `false` is kept, does not start a receiver, and does not require transmitter metadata. Sign-in is unchanged either way: WS-Federation does not send OAuth scopes. Any other value raises a config error whose message is `signals must be true or false`.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
docker compose build
|
|
28
|
+
docker run --rm --user "$(id -u):$(id -g)" \
|
|
29
|
+
-v "$PWD/demo:/app/demo" \
|
|
30
|
+
wsfed-caep-python-demo \
|
|
31
|
+
wsfed-caep generate-keys --config demo/wsfed.json
|
|
32
|
+
docker run --rm --user "$(id -u):$(id -g)" \
|
|
33
|
+
-v "$PWD/demo:/app/demo" \
|
|
34
|
+
wsfed-caep-python-demo \
|
|
35
|
+
wsfed-caep metadata --config demo/wsfed.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The generated certificate's common name defaults to `wsfed-caep-nodejs relying party` plus the realm, cut at 64 characters.
|
|
39
|
+
|
|
40
|
+
## Tests
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
docker build --target test .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The suite runs in a container.
|
|
47
|
+
|
|
48
|
+
The suite includes a stub identity provider. Those tests do not start iya-sts.
|
|
49
|
+
|
|
50
|
+
`python3 scripts/live_iya_sts.py` is the live half. It uses iya-sts (the latest main branch, in memory mode with persistence and clustering off, when `IYA_STS_URL` is unset; `IYA_STS_IMAGE` starts a fresh container from an image), copies this checkout to a scratch build context, builds the demo image, runs the install script inside it (which registers the demo, generates the key pair and writes the `idpCa` chain), then builds the demo image again so it carries the chain, checks the image holds the chain and no key, starts the demo with no CA mount, signs in through `GET /login`, and requires a credential-change event and a session-revoked event on `GET /live-status`. The second event ends the local session. It uses the host's Python and Docker and does not import this library. It uses two high ports (defaults 18556 and 18656, `LIVE_STS_PORT` and `LIVE_DEMO_PORT`) and never binds 3000, 8081 or 18481-18495. No secret appears on a docker command line, and sign-in requests are logged by fixed labels, not URLs. The nightly workflow (06:35 UTC) runs the unit suite and this script once every 24 hours.
|
|
51
|
+
|
|
52
|
+
## Layout
|
|
53
|
+
|
|
54
|
+
- `src/wsfed_caep/` library
|
|
55
|
+
- `tests/` pytest, fixtures, stub identity provider
|
|
56
|
+
- `demo/app.py` stdlib HTTP demo
|
|
57
|
+
- `scripts/configure_iya_sts.py` registers the demo with iya-sts and writes the listener CA trust chain to the file `idpCa` names
|
|
58
|
+
- `scripts/trust_chain.py` holds `save_trust_chain()` and refreshes that file
|
|
59
|
+
- `scripts/live_iya_sts.py` live sign-in against iya-sts
|
|
60
|
+
- `.github/workflows/nightly.yml` unit suite and live script, once every 24 hours
|
|
61
|
+
- `Dockerfile`, `docker-compose.yml` (`network_mode: host`, `PORT=3000`)
|
|
62
|
+
|
|
63
|
+
## The iya-sts trust chain (`idpCa`)
|
|
64
|
+
|
|
65
|
+
`idpCa` in `demo/wsfed.json` names the iya-sts TLS listener CA trust chain the library trusts for the IdP metadata URL and the Shared Signals transmitter. The default is `file:generated/idp-ca.pem`, relative to `demo/`. The value may also be a plain path, `env:<NAME>`, or the PEM itself. The library loads every certificate in it as a trust anchor (`ssl` `load_verify_locations(cadata=...)`), so the whole chain works and OpenSSL ends the path at the self-signed Root. The presented leaf must still verify to one of those anchors (a forged leaf followed by the real CA certificates is refused), and the host name is checked against the leaf's subject alternative names (a DNS name, or an IP literal such as `127.0.0.1`).
|
|
66
|
+
|
|
67
|
+
`scripts/configure_iya_sts.py`, the install script, writes that file on every run (`scripts/trust_chain.py`, `save_trust_chain()`). It saves the whole chain iya-sts publishes at `GET /tls/server-certificate` (leaf, TLS Issuing CA, Intermediate, Root), or the chain from the TLS handshake when that path does not answer. It prints the path, the trust anchor's subject and its SHA-256 fingerprint. Compare the anchor with the Root CA on iya-sts's PKI page. A password iya-sts generates for the test user is never printed: it goes to `demo/generated/<user>.password`, mode 0600.
|
|
68
|
+
|
|
69
|
+
The demo image build copies that file into the image at `/app/demo/generated/idp-ca.pem`, so the image trusts that iya-sts with no bind mount and no fetch. Run the install script before you build the image, and rebuild the image after running the install script against a different iya-sts. `.dockerignore` admits only that file from `demo/generated`: the relying party's key, certificate and the IdP metadata stay out of the image (compose still mounts `demo/generated` read-only for them). A build with no chain file yet (a fresh clone, CI) still succeeds; the image then has no `idpCa` file.
|
|
70
|
+
|
|
71
|
+
The mechanism is `COPY demo ./demo` plus `.dockerignore`'s `demo/generated/*` and `!demo/generated/idp-ca.pem`. Do not replace it with a COPY that names the file or globs for it: when the file is absent, both BuildKit and the classic builder fail. A directory COPY of `demo/` (which always exists) succeeds in both. `python scripts/trust_chain.py --config demo/wsfed.json --idp <iya-sts URL>` refreshes the file by hand.
|
|
72
|
+
|
|
73
|
+
## Demo
|
|
74
|
+
|
|
75
|
+
`PORT` defaults to 3000. A loopback realm and reply URL follow that port. Any other host stays as written. `PUBLIC_URL` or `--public-url` replaces the origin and keeps the path. The default `http://localhost:3000` is not an explicit public URL. `GET /healthz` returns `200` and the body `ok` even when generated keys are absent. `GET /login` redirects to the identity provider with `wa=wsignin1.0`, `wtrealm`, `wreply`, and `wctx`. The reply URL accepts the sign-in POST and `wsignoutcleanup1.0`. `POST /logout` signs out, including at the identity provider. `GET /live-status` is JSON: `signedIn`, `subject`, the receiver state, and the Shared Signals events this process has accepted (`type` and `jti` only). No token is in that body.
|
|
76
|
+
|
|
77
|
+
Demo JSON uses `file:` paths under `demo/generated/`. Those keys are not in the tree. `"signals"` is omitted, so the receiver is on when a transmitter is configured.
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
PORT=40123 docker compose up --build
|
|
81
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wsfed-caep"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Server-side WS-Federation relying party that accepts SAML 2.0 assertions, and a CAEP and RISC receiver."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "Iya CyberSecurity Solutions, LLC" }]
|
|
14
|
+
keywords = ["ws-federation", "wsfed", "saml2", "caep", "risc", "shared-signals"]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"cryptography>=42",
|
|
17
|
+
"lxml>=5",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = ["pytest>=8"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
wsfed-caep = "wsfed_caep.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
pythonpath = [".", "src", "tests"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/rcbj/wsfed-caep-python"
|
|
35
|
+
Repository = "https://github.com/rcbj/wsfed-caep-python"
|
|
36
|
+
Issues = "https://github.com/rcbj/wsfed-caep-python/issues"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Server-side WS-Federation relying party that accepts SAML 2.0 assertions, and a CAEP and RISC receiver.
|
|
4
|
+
|
|
5
|
+
``signals: false`` turns the receiver off. The relying party does not start one itself. The default is
|
|
6
|
+
on. Sign-in does not change, because WS-Federation does not send OAuth scopes. The flag only gates the
|
|
7
|
+
receiver and the requirement for transmitter metadata.
|
|
8
|
+
|
|
9
|
+
from wsfed_caep import RelyingParty
|
|
10
|
+
|
|
11
|
+
rp = RelyingParty.from_file("wsfed.json")
|
|
12
|
+
request = rp.login(relay_state="/")
|
|
13
|
+
who = rp.handle_reply({"method": method, "url": url, "body": body}, request_id)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from wsfed_caep.config import read_config_file, resolve_config
|
|
17
|
+
from wsfed_caep.errors import WsFedError
|
|
18
|
+
from wsfed_caep.metadata import IdpMetadata
|
|
19
|
+
from wsfed_caep.relying_party import RelyingParty
|
|
20
|
+
from wsfed_caep.replay import MemoryReplayCache
|
|
21
|
+
from wsfed_caep.ssf import (
|
|
22
|
+
CAEP_EVENTS,
|
|
23
|
+
DEFAULT_ENDING_EVENTS,
|
|
24
|
+
DEFAULT_EVENTS_REQUESTED,
|
|
25
|
+
EVENT,
|
|
26
|
+
RISC_EVENTS,
|
|
27
|
+
MemorySessionIndex,
|
|
28
|
+
SignalsReceiver,
|
|
29
|
+
subject_of,
|
|
30
|
+
tracked_from,
|
|
31
|
+
)
|
|
32
|
+
from wsfed_caep.ssf.session_index import DEFAULT_SUBJECT_ATTRIBUTE
|
|
33
|
+
from wsfed_caep.token import WsFedLogin
|
|
34
|
+
from wsfed_caep.x509util import KeyPair, create_self_signed_key_pair
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"CAEP_EVENTS",
|
|
40
|
+
"DEFAULT_ENDING_EVENTS",
|
|
41
|
+
"DEFAULT_EVENTS_REQUESTED",
|
|
42
|
+
"DEFAULT_SUBJECT_ATTRIBUTE",
|
|
43
|
+
"EVENT",
|
|
44
|
+
"IdpMetadata",
|
|
45
|
+
"KeyPair",
|
|
46
|
+
"MemoryReplayCache",
|
|
47
|
+
"MemorySessionIndex",
|
|
48
|
+
"RISC_EVENTS",
|
|
49
|
+
"RelyingParty",
|
|
50
|
+
"SignalsReceiver",
|
|
51
|
+
"WsFedError",
|
|
52
|
+
"WsFedLogin",
|
|
53
|
+
"create_self_signed_key_pair",
|
|
54
|
+
"read_config_file",
|
|
55
|
+
"resolve_config",
|
|
56
|
+
"subject_of",
|
|
57
|
+
"tracked_from",
|
|
58
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""wsfed-caep command line: generate-keys and metadata."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from wsfed_caep.config import resolve_config
|
|
13
|
+
from wsfed_caep.relying_party import RelyingParty
|
|
14
|
+
from wsfed_caep.x509util import create_self_signed_key_pair
|
|
15
|
+
|
|
16
|
+
USAGE = """Usage:
|
|
17
|
+
wsfed-caep generate-keys --config <wsfed.json> [--cn <name>] [--days <n>] [--bits <n>] [--force]
|
|
18
|
+
wsfed-caep generate-keys --key <file> --cert <file> [--cn <name>] [--days <n>] [--bits <n>] [--force]
|
|
19
|
+
A new RSA key and a self-signed certificate, written where the configuration's privateKey and
|
|
20
|
+
certificate point (or to --key and --cert). Refuses to replace existing files without --force.
|
|
21
|
+
The key decrypts encrypted assertions and can authenticate the Shared Signals receiver (mutual
|
|
22
|
+
TLS); WS-Federation itself signs nothing. The common name defaults to one naming the realm.
|
|
23
|
+
|
|
24
|
+
wsfed-caep metadata --config <wsfed.json> [--out <file>]
|
|
25
|
+
This relying party's federation metadata, from the configuration (and its certificate, when it
|
|
26
|
+
has one): give it to the identity provider. Written to --out, or to standard output."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_args(argv: list[str]) -> tuple[str | None, dict]:
|
|
30
|
+
if not argv:
|
|
31
|
+
return None, {}
|
|
32
|
+
command, rest = argv[0], argv[1:]
|
|
33
|
+
args: dict = {}
|
|
34
|
+
i = 0
|
|
35
|
+
while i < len(rest):
|
|
36
|
+
item = rest[i]
|
|
37
|
+
if not item.startswith("--"):
|
|
38
|
+
raise SystemExit(f"unexpected argument {item}\n\n{USAGE}")
|
|
39
|
+
key = item[2:]
|
|
40
|
+
if key in ("force", "help"):
|
|
41
|
+
args[key] = True
|
|
42
|
+
else:
|
|
43
|
+
if i + 1 >= len(rest) or rest[i + 1].startswith("--"):
|
|
44
|
+
raise SystemExit(f"--{key} needs a value\n\n{USAGE}")
|
|
45
|
+
args[key] = rest[i + 1]
|
|
46
|
+
i += 1
|
|
47
|
+
i += 1
|
|
48
|
+
return command, args
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _strip_file(value: str) -> str:
|
|
52
|
+
return value[5:] if value.startswith("file:") else value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def key_paths(args: dict) -> tuple[str, str, str]:
|
|
56
|
+
config_path = args.get("config") if isinstance(args.get("config"), str) else None
|
|
57
|
+
key = args.get("key") if isinstance(args.get("key"), str) else None
|
|
58
|
+
cert = args.get("cert") if isinstance(args.get("cert"), str) else None
|
|
59
|
+
common_name = args.get("cn") if isinstance(args.get("cn"), str) else None
|
|
60
|
+
if config_path:
|
|
61
|
+
raw = json.loads(Path(config_path).read_text(encoding="utf-8"))
|
|
62
|
+
base = Path(config_path).resolve().parent
|
|
63
|
+
|
|
64
|
+
def at(value, what):
|
|
65
|
+
if not value or "-----BEGIN " in value:
|
|
66
|
+
raise SystemExit(f"{config_path}'s {what} must be a file path for generate-keys to write to")
|
|
67
|
+
value = _strip_file(value)
|
|
68
|
+
path = Path(value)
|
|
69
|
+
return path if path.is_absolute() else base / path
|
|
70
|
+
|
|
71
|
+
if key is None and raw.get("privateKey"):
|
|
72
|
+
key = str(at(raw.get("privateKey"), "privateKey"))
|
|
73
|
+
if cert is None and raw.get("certificate"):
|
|
74
|
+
cert = str(at(raw.get("certificate"), "certificate"))
|
|
75
|
+
if not common_name and raw.get("realm"):
|
|
76
|
+
common_name = f"wsfed-caep-nodejs relying party {raw['realm']}"[:64]
|
|
77
|
+
if not key or not cert:
|
|
78
|
+
raise SystemExit(f"generate-keys needs --config, or --key and --cert\n\n{USAGE}")
|
|
79
|
+
return str(Path(key).resolve()), str(Path(cert).resolve()), common_name or "wsfed-caep-nodejs relying party"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main(argv: list[str] | None = None, out=None) -> None:
|
|
83
|
+
if argv is None:
|
|
84
|
+
argv = sys.argv[1:]
|
|
85
|
+
if out is None:
|
|
86
|
+
out = sys.stdout.write
|
|
87
|
+
command, args = parse_args(list(argv))
|
|
88
|
+
if not command or args.get("help") or command == "help":
|
|
89
|
+
out(USAGE + "\n")
|
|
90
|
+
return
|
|
91
|
+
if command == "generate-keys":
|
|
92
|
+
key, cert, common_name = key_paths(args)
|
|
93
|
+
if not args.get("force") and (os.path.exists(key) or os.path.exists(cert)):
|
|
94
|
+
which = key if os.path.exists(key) else cert
|
|
95
|
+
raise SystemExit(
|
|
96
|
+
f"{which} already exists; pass --force to replace the key pair.\n"
|
|
97
|
+
"Replacing it means giving the identity provider the new metadata before it next encrypts to this relying party."
|
|
98
|
+
)
|
|
99
|
+
days = int(args["days"]) if isinstance(args.get("days"), str) else None
|
|
100
|
+
bits = int(args["bits"]) if isinstance(args.get("bits"), str) else None
|
|
101
|
+
if days is not None and days <= 0:
|
|
102
|
+
raise SystemExit("--days must be a positive whole number")
|
|
103
|
+
if bits is not None and bits not in (2048, 3072, 4096):
|
|
104
|
+
raise SystemExit("--bits must be 2048, 3072 or 4096")
|
|
105
|
+
pair = create_self_signed_key_pair(common_name, days=days or 1095, bits=bits or 3072)
|
|
106
|
+
Path(key).parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
Path(cert).parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
fd = os.open(key, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
109
|
+
try:
|
|
110
|
+
os.write(fd, pair.private_key_pem.encode())
|
|
111
|
+
finally:
|
|
112
|
+
os.close(fd)
|
|
113
|
+
Path(cert).write_text(pair.certificate_pem, encoding="utf-8")
|
|
114
|
+
os.chmod(cert, 0o644)
|
|
115
|
+
out(f"Wrote the private key {key} (mode 600)\n")
|
|
116
|
+
out(f"Wrote the certificate {cert}\n")
|
|
117
|
+
out(f" subject {pair.certificate.subject.rfc4514_string()}\n")
|
|
118
|
+
return
|
|
119
|
+
if command == "metadata":
|
|
120
|
+
config_path = args.get("config") if isinstance(args.get("config"), str) else None
|
|
121
|
+
if not config_path:
|
|
122
|
+
raise SystemExit(f"metadata needs --config\n\n{USAGE}")
|
|
123
|
+
raw = json.loads(Path(config_path).read_text(encoding="utf-8"))
|
|
124
|
+
raw.pop("idpCa", None)
|
|
125
|
+
xml = RelyingParty(resolve_config(raw, str(Path(config_path).resolve().parent)), None).metadata()
|
|
126
|
+
target = args.get("out") if isinstance(args.get("out"), str) else None
|
|
127
|
+
if target:
|
|
128
|
+
path = Path(target).resolve()
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
path.write_text(xml, encoding="utf-8")
|
|
131
|
+
out(f"Wrote the relying party metadata to {path}\n")
|
|
132
|
+
else:
|
|
133
|
+
out(xml)
|
|
134
|
+
return
|
|
135
|
+
raise SystemExit(f"unknown command {command}\n\n{USAGE}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
try:
|
|
140
|
+
main()
|
|
141
|
+
except SystemExit as err:
|
|
142
|
+
if err.code not in (None, 0):
|
|
143
|
+
sys.stderr.write(f"{err.code if isinstance(err.code, str) else err}\n" if False else (f"{err.code}\n" if isinstance(err.code, str) else ""))
|
|
144
|
+
if isinstance(err.code, str):
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
raise
|
|
147
|
+
except Exception as err:
|
|
148
|
+
sys.stderr.write(f"{err}\n")
|
|
149
|
+
sys.exit(1)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Configuration. signals defaults to on. false is recorded; true and omitted are left off the result."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from cryptography import x509
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
15
|
+
|
|
16
|
+
from wsfed_caep.errors import WsFedError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResolvedConfig:
|
|
20
|
+
def __init__(self, **kwargs):
|
|
21
|
+
self.__dict__.update(kwargs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _pem(value: str) -> bool:
|
|
25
|
+
return "-----BEGIN " in value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _material(value: str, base_dir: str, what: str) -> str:
|
|
29
|
+
if value.startswith("env:"):
|
|
30
|
+
name = value[4:]
|
|
31
|
+
if not os.environ.get(name):
|
|
32
|
+
raise WsFedError("config", f"{what} environment variable {name} is not set")
|
|
33
|
+
value = os.environ[name]
|
|
34
|
+
if value.startswith("file:"):
|
|
35
|
+
value = value[5:]
|
|
36
|
+
if _pem(value):
|
|
37
|
+
return value
|
|
38
|
+
path = value if os.path.isabs(value) else str(Path(base_dir) / value)
|
|
39
|
+
try:
|
|
40
|
+
return Path(path).read_text(encoding="utf-8")
|
|
41
|
+
except OSError as err:
|
|
42
|
+
raise WsFedError("config", f"{what} is neither a PEM nor a readable file ({path})", err) from err
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _absolute_url(value, what: str) -> str:
|
|
46
|
+
if not isinstance(value, str) or not value.strip():
|
|
47
|
+
raise WsFedError("config", f"{what} is required")
|
|
48
|
+
try:
|
|
49
|
+
parsed = urlparse(value)
|
|
50
|
+
if parsed.scheme not in ("https", "http") or not parsed.netloc:
|
|
51
|
+
raise ValueError("not http(s)")
|
|
52
|
+
except Exception:
|
|
53
|
+
raise WsFedError("config", f"{what} must be an absolute http(s) URL, not {value}") from None
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _flag(value, default: bool, what: str) -> bool:
|
|
58
|
+
if value is None:
|
|
59
|
+
return default
|
|
60
|
+
if not isinstance(value, bool):
|
|
61
|
+
raise WsFedError("config", f"{what} must be true or false")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _number(value) -> bool:
|
|
66
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _idp_metadata(value: str, base_dir: str) -> str:
|
|
70
|
+
value = value.strip()
|
|
71
|
+
if value.startswith("env:"):
|
|
72
|
+
name = value[4:]
|
|
73
|
+
if not os.environ.get(name):
|
|
74
|
+
raise WsFedError("config", f"idpMetadata environment variable {name} is not set")
|
|
75
|
+
value = os.environ[name].strip()
|
|
76
|
+
if value.startswith("file:"):
|
|
77
|
+
value = value[5:]
|
|
78
|
+
if value.startswith("http://") or value.startswith("https://") or value.startswith("<"):
|
|
79
|
+
return value
|
|
80
|
+
return value if os.path.isabs(value) else str(Path(base_dir) / value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_config(config: dict, base_dir: str | None = None) -> ResolvedConfig:
|
|
84
|
+
if not isinstance(config, dict):
|
|
85
|
+
raise WsFedError("config", "the configuration must be an object")
|
|
86
|
+
base = base_dir or os.getcwd()
|
|
87
|
+
realm = config.get("realm")
|
|
88
|
+
if not isinstance(realm, str) or not realm.strip():
|
|
89
|
+
raise WsFedError("config", "realm is required")
|
|
90
|
+
reply_url = _absolute_url(config.get("replyUrl"), "replyUrl")
|
|
91
|
+
raw_metadata = config.get("idpMetadata")
|
|
92
|
+
if not isinstance(raw_metadata, str) or not raw_metadata.strip():
|
|
93
|
+
raise WsFedError("config", "idpMetadata is required")
|
|
94
|
+
skew = 60 if config.get("clockSkewSeconds") is None else config.get("clockSkewSeconds")
|
|
95
|
+
if not _number(skew) or skew < 0 or skew > 600 or skew != skew:
|
|
96
|
+
raise WsFedError("config", "clockSkewSeconds must be a number from 0 to 600")
|
|
97
|
+
signals = _flag(config.get("signals") if "signals" in config else None, True, "signals")
|
|
98
|
+
# A missing key and an explicit JSON null are both the default. A non-boolean is an error.
|
|
99
|
+
if "signals" in config:
|
|
100
|
+
signals = _flag(config.get("signals"), True, "signals")
|
|
101
|
+
out = ResolvedConfig(
|
|
102
|
+
realm=realm,
|
|
103
|
+
reply_url=reply_url,
|
|
104
|
+
idp_metadata=_idp_metadata(raw_metadata, base),
|
|
105
|
+
clock_skew_ms=int(skew * 1000) if isinstance(skew, float) and not isinstance(skew, int) else int(skew) * 1000,
|
|
106
|
+
want_assertions_encrypted=_flag(config.get("wantAssertionsEncrypted") if "wantAssertionsEncrypted" in config else None, False, "wantAssertionsEncrypted"),
|
|
107
|
+
allow_unsolicited=_flag(config.get("allowUnsolicited") if "allowUnsolicited" in config else None, False, "allowUnsolicited"),
|
|
108
|
+
request_token_type=_flag(config.get("requestTokenType") if "requestTokenType" in config else None, True, "requestTokenType"),
|
|
109
|
+
)
|
|
110
|
+
if "wantAssertionsEncrypted" in config:
|
|
111
|
+
out.want_assertions_encrypted = _flag(config.get("wantAssertionsEncrypted"), False, "wantAssertionsEncrypted")
|
|
112
|
+
if "allowUnsolicited" in config:
|
|
113
|
+
out.allow_unsolicited = _flag(config.get("allowUnsolicited"), False, "allowUnsolicited")
|
|
114
|
+
if "requestTokenType" in config:
|
|
115
|
+
out.request_token_type = _flag(config.get("requestTokenType"), True, "requestTokenType")
|
|
116
|
+
if not signals:
|
|
117
|
+
out.signals = False
|
|
118
|
+
has_key = config.get("privateKey") is not None
|
|
119
|
+
has_cert = config.get("certificate") is not None
|
|
120
|
+
if has_key != has_cert:
|
|
121
|
+
raise WsFedError("config", "privateKey and certificate go together: give both or neither")
|
|
122
|
+
if has_key and has_cert:
|
|
123
|
+
try:
|
|
124
|
+
private_key = serialization.load_pem_private_key(_material(config["privateKey"], base, "privateKey").encode(), password=None)
|
|
125
|
+
certificate = x509.load_pem_x509_certificate(_material(config["certificate"], base, "certificate").encode())
|
|
126
|
+
except WsFedError:
|
|
127
|
+
raise
|
|
128
|
+
except Exception as err:
|
|
129
|
+
raise WsFedError("config", f"the private key or certificate cannot be read: {err}", err) from err
|
|
130
|
+
if not isinstance(private_key, rsa.RSAPrivateKey):
|
|
131
|
+
raise WsFedError("config", f"the private key is {type(private_key).__name__}; an RSA key is required")
|
|
132
|
+
if certificate.public_key().public_numbers() != private_key.public_key().public_numbers():
|
|
133
|
+
raise WsFedError("config", "the certificate is not for the private key")
|
|
134
|
+
out.private_key = private_key
|
|
135
|
+
out.certificate = certificate
|
|
136
|
+
elif out.want_assertions_encrypted:
|
|
137
|
+
raise WsFedError("config", "wantAssertionsEncrypted needs privateKey and certificate: the assertion is encrypted to them")
|
|
138
|
+
if config.get("idpCa"):
|
|
139
|
+
out.idp_ca = _material(config["idpCa"], base, "idpCa")
|
|
140
|
+
if config.get("idpEntityId"):
|
|
141
|
+
out.idp_entity_id = config["idpEntityId"]
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_config_file(path: str, overrides: dict | None = None) -> ResolvedConfig:
|
|
146
|
+
overrides = overrides or {}
|
|
147
|
+
try:
|
|
148
|
+
parsed = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
149
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
150
|
+
raise WsFedError("config", f"{path} cannot be read as JSON: {err}", err) from err
|
|
151
|
+
defined = {key: value for key, value in overrides.items() if value is not None}
|
|
152
|
+
merged = {**parsed, **defined}
|
|
153
|
+
return resolve_config(merged, str(Path(path).resolve().parent))
|