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.
Files changed (35) hide show
  1. wsfed_caep-0.1.0/LICENSE +21 -0
  2. wsfed_caep-0.1.0/PKG-INFO +100 -0
  3. wsfed_caep-0.1.0/README.md +81 -0
  4. wsfed_caep-0.1.0/pyproject.toml +36 -0
  5. wsfed_caep-0.1.0/setup.cfg +4 -0
  6. wsfed_caep-0.1.0/src/wsfed_caep/__init__.py +58 -0
  7. wsfed_caep-0.1.0/src/wsfed_caep/cli.py +149 -0
  8. wsfed_caep-0.1.0/src/wsfed_caep/config.py +153 -0
  9. wsfed_caep-0.1.0/src/wsfed_caep/encryption.py +128 -0
  10. wsfed_caep-0.1.0/src/wsfed_caep/errors.py +13 -0
  11. wsfed_caep-0.1.0/src/wsfed_caep/http_client.py +82 -0
  12. wsfed_caep-0.1.0/src/wsfed_caep/metadata.py +153 -0
  13. wsfed_caep-0.1.0/src/wsfed_caep/relying_party.py +195 -0
  14. wsfed_caep-0.1.0/src/wsfed_caep/replay.py +30 -0
  15. wsfed_caep-0.1.0/src/wsfed_caep/signature.py +157 -0
  16. wsfed_caep-0.1.0/src/wsfed_caep/ssf/__init__.py +27 -0
  17. wsfed_caep-0.1.0/src/wsfed_caep/ssf/events.py +103 -0
  18. wsfed_caep-0.1.0/src/wsfed_caep/ssf/jws.py +116 -0
  19. wsfed_caep-0.1.0/src/wsfed_caep/ssf/receiver.py +310 -0
  20. wsfed_caep-0.1.0/src/wsfed_caep/ssf/session_index.py +78 -0
  21. wsfed_caep-0.1.0/src/wsfed_caep/token.py +293 -0
  22. wsfed_caep-0.1.0/src/wsfed_caep/x509util.py +144 -0
  23. wsfed_caep-0.1.0/src/wsfed_caep/xmlutil.py +172 -0
  24. wsfed_caep-0.1.0/src/wsfed_caep.egg-info/PKG-INFO +100 -0
  25. wsfed_caep-0.1.0/src/wsfed_caep.egg-info/SOURCES.txt +33 -0
  26. wsfed_caep-0.1.0/src/wsfed_caep.egg-info/dependency_links.txt +1 -0
  27. wsfed_caep-0.1.0/src/wsfed_caep.egg-info/entry_points.txt +2 -0
  28. wsfed_caep-0.1.0/src/wsfed_caep.egg-info/requires.txt +5 -0
  29. wsfed_caep-0.1.0/src/wsfed_caep.egg-info/top_level.txt +1 -0
  30. wsfed_caep-0.1.0/tests/test_configure.py +144 -0
  31. wsfed_caep-0.1.0/tests/test_http.py +114 -0
  32. wsfed_caep-0.1.0/tests/test_rp.py +345 -0
  33. wsfed_caep-0.1.0/tests/test_ssf.py +323 -0
  34. wsfed_caep-0.1.0/tests/test_trust_chain.py +295 -0
  35. wsfed_caep-0.1.0/tests/test_x509.py +67 -0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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))