saml2-sp 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. saml2_sp-0.1.0/LICENSE +21 -0
  2. saml2_sp-0.1.0/PKG-INFO +109 -0
  3. saml2_sp-0.1.0/README.md +91 -0
  4. saml2_sp-0.1.0/pyproject.toml +35 -0
  5. saml2_sp-0.1.0/setup.cfg +4 -0
  6. saml2_sp-0.1.0/src/saml2_sp/__init__.py +35 -0
  7. saml2_sp-0.1.0/src/saml2_sp/artifact.py +86 -0
  8. saml2_sp-0.1.0/src/saml2_sp/backchannel.py +94 -0
  9. saml2_sp-0.1.0/src/saml2_sp/bindings.py +129 -0
  10. saml2_sp-0.1.0/src/saml2_sp/cli.py +147 -0
  11. saml2_sp-0.1.0/src/saml2_sp/config.py +156 -0
  12. saml2_sp-0.1.0/src/saml2_sp/encryption.py +120 -0
  13. saml2_sp-0.1.0/src/saml2_sp/errors.py +28 -0
  14. saml2_sp-0.1.0/src/saml2_sp/metadata.py +116 -0
  15. saml2_sp-0.1.0/src/saml2_sp/replay.py +38 -0
  16. saml2_sp-0.1.0/src/saml2_sp/response.py +261 -0
  17. saml2_sp-0.1.0/src/saml2_sp/service_provider.py +207 -0
  18. saml2_sp-0.1.0/src/saml2_sp/signature.py +251 -0
  19. saml2_sp-0.1.0/src/saml2_sp/ssf/__init__.py +20 -0
  20. saml2_sp-0.1.0/src/saml2_sp/ssf/events.py +92 -0
  21. saml2_sp-0.1.0/src/saml2_sp/ssf/jws.py +122 -0
  22. saml2_sp-0.1.0/src/saml2_sp/ssf/receiver.py +307 -0
  23. saml2_sp-0.1.0/src/saml2_sp/ssf/session_index.py +88 -0
  24. saml2_sp-0.1.0/src/saml2_sp/x509util.py +96 -0
  25. saml2_sp-0.1.0/src/saml2_sp/xml.py +120 -0
  26. saml2_sp-0.1.0/src/saml2_sp.egg-info/PKG-INFO +109 -0
  27. saml2_sp-0.1.0/src/saml2_sp.egg-info/SOURCES.txt +33 -0
  28. saml2_sp-0.1.0/src/saml2_sp.egg-info/dependency_links.txt +1 -0
  29. saml2_sp-0.1.0/src/saml2_sp.egg-info/entry_points.txt +2 -0
  30. saml2_sp-0.1.0/src/saml2_sp.egg-info/requires.txt +5 -0
  31. saml2_sp-0.1.0/src/saml2_sp.egg-info/top_level.txt +1 -0
  32. saml2_sp-0.1.0/tests/test_configure.py +177 -0
  33. saml2_sp-0.1.0/tests/test_sp.py +400 -0
  34. saml2_sp-0.1.0/tests/test_ssf.py +319 -0
  35. saml2_sp-0.1.0/tests/test_trust_chain.py +216 -0
saml2_sp-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,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: saml2-sp
3
+ Version: 0.1.0
4
+ Summary: Server-side SAML 2.0 service provider: signed AuthnRequests, HTTP-POST, HTTP-Redirect and HTTP-Artifact responses, encrypted assertions, and a Shared Signals poll receiver.
5
+ Author: Iya CyberSecurity Solutions, LLC
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rcbj/saml2-sp-python
8
+ Project-URL: Repository, https://github.com/rcbj/saml2-sp-python
9
+ Project-URL: Issues, https://github.com/rcbj/saml2-sp-python/issues
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cryptography>=42
14
+ Requires-Dist: lxml>=5
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # saml2-sp
20
+
21
+ Server-side SAML 2.0 service provider for Python. It sends a signed `AuthnRequest` on HTTP-Redirect or HTTP-POST, accepts a `Response` on HTTP-POST, HTTP-Redirect, or HTTP-Artifact, verifies XML signatures, decrypts encrypted assertions, and can poll a Shared Signals transmitter for CAEP and RISC events.
22
+
23
+ Version 0.1.0. License: MIT. `develop` and `main` are one commit. 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`).
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from saml2_sp import ServiceProvider
29
+
30
+ sp = ServiceProvider.from_file("saml.json")
31
+ request = sp.login(relay_state="/home")
32
+ # request.binding is "redirect" (a URL) or "post" (request.html, an auto-posting form)
33
+ who = sp.acs(method, url, body, request_id=request.id)
34
+ ```
35
+
36
+ `saml.json` uses the same keys as the Node service provider: `entityId`, `acsUrl`, `privateKey`, `certificate`, `idpMetadata`, and the optional `requestBinding`, `responseBinding`, `idpCa`, `idpEntityId`, `nameIdFormat`, `clockSkewSeconds`, `wantAssertionsSigned`, `wantAssertionsEncrypted`, `allowUnsolicited`, and `signals`. A value may be PEM text, a file path, or an `env:` / `file:` placeholder. `signals` omitted or `true` leaves the Shared Signals receiver available. `false` turns it off and does not require transmitter metadata. Any other value is a configuration error: `signals must be true or false`. The resolved configuration omits `signals` when the receiver is on and includes `signals: false` when it is off. SAML does not send OAuth scopes, so the flag does not change the `AuthnRequest`.
37
+
38
+ ```sh
39
+ docker compose build
40
+ docker run --rm --user "$(id -u):$(id -g)" \
41
+ -v "$PWD/demo:/app/demo" \
42
+ saml2-sp-python-demo \
43
+ saml2-sp generate-keys --config demo/saml.json
44
+ docker run --rm --user "$(id -u):$(id -g)" \
45
+ -v "$PWD/demo:/app/demo" \
46
+ saml2-sp-python-demo \
47
+ saml2-sp metadata --config demo/saml.json
48
+ ```
49
+
50
+ ## Shared Signals
51
+
52
+ ```python
53
+ from saml2_sp import SignalsReceiver
54
+
55
+ receiver = SignalsReceiver(transmitter="https://idp.example", on_end_sessions=end)
56
+ receiver.track(local_id, login)
57
+ receiver.start()
58
+ ```
59
+
60
+ The receiver discovers the transmitter, keeps one poll stream (`urn:ietf:rfc:8936`), and verifies each Security Event Token against the published JWKS. By default a session ends on `session-revoked`, `account-disabled`, `account-purged`, `credential-compromise`, and `sessions-revoked`.
61
+
62
+ ## Tests
63
+
64
+ ```bash
65
+ docker build --target test .
66
+ ```
67
+
68
+ The suite runs in a container.
69
+
70
+ The tests sign, encrypt, and decrypt with `cryptography` and exclusive canonicalization from `lxml`. They include a stub identity provider and a stub transmitter. Those stub tests do not start iya-sts.
71
+
72
+ `python3 scripts/live_iya_sts.py` is the live half. It starts the latest main branch of iya-sts in memory mode with persistence and clustering off when `IYA_STS_URL` is unset, runs the install script in a scratch copy of this checkout (which registers the demo and writes the `idpCa` chain), then builds this demo image from it, checks the image carries the chain and no key, starts the demo with no CA mount, signs in, and requires a credential-change event and a session-revoked event on `GET /live-status`. The second event ends the local session. It uses two high ports (defaults 18546 and 18646, `LIVE_STS_PORT` and `LIVE_DEMO_PORT`) and never binds 3000, 8081 or 18481-18495. The nightly workflow runs the unit suite and this script once every 24 hours.
73
+
74
+ ## Layout
75
+
76
+ - `src/saml2_sp/` the library (`ServiceProvider`, metadata, bindings, signatures, encryption, the replay cache, `ssf/`)
77
+ - `tests/` pytest, including the stub identity provider
78
+ - `demo/` the stdlib `http.server` application
79
+ - `scripts/configure_iya_sts.py` registers that demo with iya-sts and writes the listener CA trust chain to the file `idpCa` names
80
+ - `scripts/trust_chain.py` holds `save_trust_chain()` and refreshes that file
81
+ - `scripts/live_iya_sts.py` signs the demo in to iya-sts and checks `GET /live-status`
82
+ - `.github/workflows/nightly.yml` runs the unit suite and the live script once every 24 hours
83
+ - `Dockerfile` and `docker-compose.yml` run the demo (`network_mode: host`, `PORT` defaults to 3000)
84
+
85
+ ## The iya-sts trust chain (`idpCa`)
86
+
87
+ `idpCa` in `demo/saml.json` names the iya-sts TLS listener CA trust chain the library trusts for the IdP metadata URL, artifact resolution 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 into the default trust store (`ssl` `load_verify_locations(cadata=...)`), so the whole chain works and OpenSSL ends the path at the self-signed Root.
88
+
89
+ `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.
90
+
91
+ 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 SP key, certificate and 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.
92
+
93
+ 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/saml.json --idp <iya-sts URL>` refreshes the file by hand.
94
+
95
+ ## Demo
96
+
97
+ ```bash
98
+ docker compose up --build
99
+ ```
100
+
101
+ `PORT` defaults to 3000. `GET /healthz` returns `200` and the body `ok` even when the identity provider metadata is not on disk yet. The home page offers sign-in. `/login` answers a signed AuthnRequest (redirect, or a POST form). `/saml/acs` is the assertion consumer. `/saml/metadata` is this service provider's metadata. `GET /live-status` does not need an account id. It is JSON: `signedIn` (true when any local account session exists), `subject`, the receiver state, and the Shared Signals events this process has accepted (`type` and `jti` only). A loopback `entityId` and assertion consumer URL follow `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 treated as an explicit public URL. The live script never binds port 3000 or 8081.
102
+
103
+ `demo/saml.json` points at `file:` paths under `demo/generated/` and does not contain a key. Generate the key pair and, against a running iya-sts, register the application with:
104
+
105
+ ```bash
106
+ python scripts/configure_iya_sts.py
107
+ ```
108
+
109
+ The container's `PORT` wins over the shell's `PORT` when that script chooses the entityID and assertion consumer URL. The shipped demo leaves `signals` unset, so the receiver stays on when `ssf.transmitter` is set.
@@ -0,0 +1,91 @@
1
+ # saml2-sp
2
+
3
+ Server-side SAML 2.0 service provider for Python. It sends a signed `AuthnRequest` on HTTP-Redirect or HTTP-POST, accepts a `Response` on HTTP-POST, HTTP-Redirect, or HTTP-Artifact, verifies XML signatures, decrypts encrypted assertions, and can poll a Shared Signals transmitter for CAEP and RISC events.
4
+
5
+ Version 0.1.0. License: MIT. `develop` and `main` are one commit. 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`).
6
+
7
+ ## Usage
8
+
9
+ ```python
10
+ from saml2_sp import ServiceProvider
11
+
12
+ sp = ServiceProvider.from_file("saml.json")
13
+ request = sp.login(relay_state="/home")
14
+ # request.binding is "redirect" (a URL) or "post" (request.html, an auto-posting form)
15
+ who = sp.acs(method, url, body, request_id=request.id)
16
+ ```
17
+
18
+ `saml.json` uses the same keys as the Node service provider: `entityId`, `acsUrl`, `privateKey`, `certificate`, `idpMetadata`, and the optional `requestBinding`, `responseBinding`, `idpCa`, `idpEntityId`, `nameIdFormat`, `clockSkewSeconds`, `wantAssertionsSigned`, `wantAssertionsEncrypted`, `allowUnsolicited`, and `signals`. A value may be PEM text, a file path, or an `env:` / `file:` placeholder. `signals` omitted or `true` leaves the Shared Signals receiver available. `false` turns it off and does not require transmitter metadata. Any other value is a configuration error: `signals must be true or false`. The resolved configuration omits `signals` when the receiver is on and includes `signals: false` when it is off. SAML does not send OAuth scopes, so the flag does not change the `AuthnRequest`.
19
+
20
+ ```sh
21
+ docker compose build
22
+ docker run --rm --user "$(id -u):$(id -g)" \
23
+ -v "$PWD/demo:/app/demo" \
24
+ saml2-sp-python-demo \
25
+ saml2-sp generate-keys --config demo/saml.json
26
+ docker run --rm --user "$(id -u):$(id -g)" \
27
+ -v "$PWD/demo:/app/demo" \
28
+ saml2-sp-python-demo \
29
+ saml2-sp metadata --config demo/saml.json
30
+ ```
31
+
32
+ ## Shared Signals
33
+
34
+ ```python
35
+ from saml2_sp import SignalsReceiver
36
+
37
+ receiver = SignalsReceiver(transmitter="https://idp.example", on_end_sessions=end)
38
+ receiver.track(local_id, login)
39
+ receiver.start()
40
+ ```
41
+
42
+ The receiver discovers the transmitter, keeps one poll stream (`urn:ietf:rfc:8936`), and verifies each Security Event Token against the published JWKS. By default a session ends on `session-revoked`, `account-disabled`, `account-purged`, `credential-compromise`, and `sessions-revoked`.
43
+
44
+ ## Tests
45
+
46
+ ```bash
47
+ docker build --target test .
48
+ ```
49
+
50
+ The suite runs in a container.
51
+
52
+ The tests sign, encrypt, and decrypt with `cryptography` and exclusive canonicalization from `lxml`. They include a stub identity provider and a stub transmitter. Those stub tests do not start iya-sts.
53
+
54
+ `python3 scripts/live_iya_sts.py` is the live half. It starts the latest main branch of iya-sts in memory mode with persistence and clustering off when `IYA_STS_URL` is unset, runs the install script in a scratch copy of this checkout (which registers the demo and writes the `idpCa` chain), then builds this demo image from it, checks the image carries the chain and no key, starts the demo with no CA mount, signs in, and requires a credential-change event and a session-revoked event on `GET /live-status`. The second event ends the local session. It uses two high ports (defaults 18546 and 18646, `LIVE_STS_PORT` and `LIVE_DEMO_PORT`) and never binds 3000, 8081 or 18481-18495. The nightly workflow runs the unit suite and this script once every 24 hours.
55
+
56
+ ## Layout
57
+
58
+ - `src/saml2_sp/` the library (`ServiceProvider`, metadata, bindings, signatures, encryption, the replay cache, `ssf/`)
59
+ - `tests/` pytest, including the stub identity provider
60
+ - `demo/` the stdlib `http.server` application
61
+ - `scripts/configure_iya_sts.py` registers that demo with iya-sts and writes the listener CA trust chain to the file `idpCa` names
62
+ - `scripts/trust_chain.py` holds `save_trust_chain()` and refreshes that file
63
+ - `scripts/live_iya_sts.py` signs the demo in to iya-sts and checks `GET /live-status`
64
+ - `.github/workflows/nightly.yml` runs the unit suite and the live script once every 24 hours
65
+ - `Dockerfile` and `docker-compose.yml` run the demo (`network_mode: host`, `PORT` defaults to 3000)
66
+
67
+ ## The iya-sts trust chain (`idpCa`)
68
+
69
+ `idpCa` in `demo/saml.json` names the iya-sts TLS listener CA trust chain the library trusts for the IdP metadata URL, artifact resolution 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 into the default trust store (`ssl` `load_verify_locations(cadata=...)`), so the whole chain works and OpenSSL ends the path at the self-signed Root.
70
+
71
+ `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.
72
+
73
+ 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 SP key, certificate and 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.
74
+
75
+ 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/saml.json --idp <iya-sts URL>` refreshes the file by hand.
76
+
77
+ ## Demo
78
+
79
+ ```bash
80
+ docker compose up --build
81
+ ```
82
+
83
+ `PORT` defaults to 3000. `GET /healthz` returns `200` and the body `ok` even when the identity provider metadata is not on disk yet. The home page offers sign-in. `/login` answers a signed AuthnRequest (redirect, or a POST form). `/saml/acs` is the assertion consumer. `/saml/metadata` is this service provider's metadata. `GET /live-status` does not need an account id. It is JSON: `signedIn` (true when any local account session exists), `subject`, the receiver state, and the Shared Signals events this process has accepted (`type` and `jti` only). A loopback `entityId` and assertion consumer URL follow `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 treated as an explicit public URL. The live script never binds port 3000 or 8081.
84
+
85
+ `demo/saml.json` points at `file:` paths under `demo/generated/` and does not contain a key. Generate the key pair and, against a running iya-sts, register the application with:
86
+
87
+ ```bash
88
+ python scripts/configure_iya_sts.py
89
+ ```
90
+
91
+ The container's `PORT` wins over the shell's `PORT` when that script chooses the entityID and assertion consumer URL. The shipped demo leaves `signals` unset, so the receiver stays on when `ssf.transmitter` is set.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "saml2-sp"
3
+ version = "0.1.0"
4
+ description = "Server-side SAML 2.0 service provider: signed AuthnRequests, HTTP-POST, HTTP-Redirect and HTTP-Artifact responses, encrypted assertions, and a Shared Signals poll receiver."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Iya CyberSecurity Solutions, LLC" }]
10
+ dependencies = [
11
+ "cryptography>=42",
12
+ "lxml>=5",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=8"]
17
+
18
+ [project.scripts]
19
+ saml2-sp = "saml2_sp.cli:main"
20
+
21
+ [build-system]
22
+ requires = ["setuptools>=77"]
23
+ build-backend = "setuptools.build_meta"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.pytest.ini_options]
29
+ pythonpath = ["src", "scripts", "tests"]
30
+ testpaths = ["tests"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/rcbj/saml2-sp-python"
34
+ Repository = "https://github.com/rcbj/saml2-sp-python"
35
+ Issues = "https://github.com/rcbj/saml2-sp-python/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """saml2-sp: a server-side SAML 2.0 service provider."""
4
+
5
+ from saml2_sp.bindings import post_form_html
6
+ from saml2_sp.config import read_config_file, resolve_config
7
+ from saml2_sp.errors import SamlError, SamlStatus
8
+ from saml2_sp.replay import MemoryReplayCache
9
+ from saml2_sp.service_provider import ServiceProvider
10
+ from saml2_sp.ssf.events import CAEP_EVENTS, DEFAULT_ENDING_EVENTS, DEFAULT_EVENTS_REQUESTED, EVENT, RISC_EVENTS, subject_of
11
+ from saml2_sp.ssf.receiver import SignalEvent, SignalsReceiver
12
+ from saml2_sp.ssf.session_index import DEFAULT_SUBJECT_ATTRIBUTE, MemorySessionIndex, tracked_from
13
+ from saml2_sp.x509util import create_self_signed_key_pair
14
+
15
+ __all__ = [
16
+ "CAEP_EVENTS",
17
+ "DEFAULT_ENDING_EVENTS",
18
+ "DEFAULT_EVENTS_REQUESTED",
19
+ "DEFAULT_SUBJECT_ATTRIBUTE",
20
+ "EVENT",
21
+ "MemoryReplayCache",
22
+ "MemorySessionIndex",
23
+ "RISC_EVENTS",
24
+ "SamlError",
25
+ "SamlStatus",
26
+ "ServiceProvider",
27
+ "SignalEvent",
28
+ "SignalsReceiver",
29
+ "create_self_signed_key_pair",
30
+ "post_form_html",
31
+ "read_config_file",
32
+ "resolve_config",
33
+ "subject_of",
34
+ "tracked_from",
35
+ ]
@@ -0,0 +1,86 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """The HTTP-Artifact binding, as the receiving side of a Response."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ from datetime import datetime, timezone
9
+
10
+ from saml2_sp.backchannel import send
11
+ from saml2_sp.errors import SamlError
12
+ from saml2_sp.signature import sign_enveloped, verify_signed
13
+ from saml2_sp.xml import NS, STATUS_SUCCESS, assert_unique_ids, attr, child, children, escape_xml, new_id, parse_xml, text
14
+
15
+
16
+ def _iso(when: datetime | None = None) -> str:
17
+ when = when or datetime.now(timezone.utc)
18
+ return when.strftime("%Y-%m-%dT%H:%M:%S.") + f"{when.microsecond // 1000:03d}Z"
19
+
20
+
21
+ def parse_artifact(saml_art: str) -> dict:
22
+ import base64
23
+ import re
24
+ raw = base64.b64decode(re.sub(r"\s+", "", saml_art))
25
+ if len(raw) != 44 or int.from_bytes(raw[0:2], "big") != 0x0004:
26
+ raise SamlError("artifact", "SAMLart is not a SAML 2.0 type 0x0004 artifact")
27
+ return {"endpoint_index": int.from_bytes(raw[2:4], "big"), "source_id": raw[4:24]}
28
+
29
+
30
+ def resolve_artifact(data: dict) -> dict:
31
+ import os
32
+ parsed = parse_artifact(data["artifact"])
33
+ expected = hashlib.sha1(data["idp_entity_id"].encode("utf-8")).digest()
34
+ if parsed["source_id"] != expected:
35
+ raise SamlError("artifact", f"the artifact was not issued by {data['idp_entity_id']}")
36
+ services: dict[int, str] = data["artifact_resolution_services"]
37
+ location = services.get(parsed["endpoint_index"])
38
+ if location is None and parsed["endpoint_index"] == 0 and services:
39
+ location = next(iter(services.values()))
40
+ if not location:
41
+ raise SamlError("artifact", f"the identity provider's metadata has no SOAP ArtifactResolutionService with index {parsed['endpoint_index']}")
42
+ element_id = new_id(os.urandom(20).hex())
43
+ unsigned = (
44
+ f'<samlp:ArtifactResolve xmlns:samlp="{NS["samlp"]}" xmlns:saml="{NS["saml"]}" ID="{element_id}" Version="2.0" '
45
+ f'IssueInstant="{_iso()}" Destination="{escape_xml(location)}">'
46
+ f'<saml:Issuer>{escape_xml(data["entity_id"])}</saml:Issuer>'
47
+ f'<samlp:Artifact>{escape_xml(data["artifact"])}</samlp:Artifact>'
48
+ "</samlp:ArtifactResolve>"
49
+ )
50
+ signed = sign_enveloped(unsigned, element_id, data["key"], data["certificate"])
51
+ envelope = (
52
+ '<?xml version="1.0" encoding="UTF-8"?>'
53
+ f'<soap:Envelope xmlns:soap="{NS["soap"]}"><soap:Body>{signed}</soap:Body></soap:Envelope>'
54
+ )
55
+ answer = send(location, method="POST", headers={
56
+ "content-type": "text/xml; charset=utf-8",
57
+ "soapaction": '"http://www.oasis-open.org/committees/security"',
58
+ "accept": "text/xml",
59
+ }, body=envelope, tls=data.get("tls") or {}, code="artifact")
60
+ if answer.status != 200:
61
+ raise SamlError("artifact", f"the artifact resolution service answered HTTP {answer.status}")
62
+ root = parse_xml(answer.body, "the ArtifactResponse")
63
+ assert_unique_ids(root)
64
+ body = child(root, NS["soap"], "Body") if root.tag == f"{{{NS['soap']}}}Envelope" else None
65
+ artifact_response = child(body, NS["samlp"], "ArtifactResponse") if body is not None else None
66
+ if artifact_response is None:
67
+ raise SamlError("artifact", "the artifact resolution service did not answer with a SOAP ArtifactResponse")
68
+ xml = answer.body
69
+ signed_response = verify_signed(artifact_response, data["idp_certificates"], "the ArtifactResponse")
70
+ if signed_response is not None:
71
+ xml = signed_response
72
+ artifact_response = parse_xml(signed_response, "the signed ArtifactResponse")
73
+ if attr(artifact_response, "InResponseTo") != element_id:
74
+ raise SamlError("artifact", "the ArtifactResponse does not answer this ArtifactResolve")
75
+ issuer = child(artifact_response, NS["saml"], "Issuer")
76
+ if issuer is not None and text(issuer) != data["idp_entity_id"]:
77
+ raise SamlError("artifact", f"the ArtifactResponse was issued by {text(issuer)}")
78
+ status = child(artifact_response, NS["samlp"], "Status")
79
+ code_el = child(status, NS["samlp"], "StatusCode") if status is not None else None
80
+ code = attr(code_el, "Value") if code_el is not None else (attr(status, "Value") if status is not None else None)
81
+ if code != STATUS_SUCCESS:
82
+ raise SamlError("artifact", f"the artifact resolution failed: {code or 'no status'}")
83
+ responses = children(artifact_response, NS["samlp"], "Response")
84
+ if not responses:
85
+ raise SamlError("artifact", "the identity provider did not resolve the artifact (unknown, expired, or already used)")
86
+ return {"xml": xml, "response": responses[0]}
@@ -0,0 +1,94 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """The back channel: identity-provider metadata and artifact resolution. TLS is verified."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import ssl
9
+ import tempfile
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ from saml2_sp.errors import SamlError
14
+
15
+ MAX_BYTES = 2 * 1024 * 1024
16
+ TIMEOUT_S = 15
17
+
18
+
19
+ class HttpAnswer:
20
+ def __init__(self, status: int, content_type: str, body: str) -> None:
21
+ self.status = status
22
+ self.content_type = content_type
23
+ self.body = body
24
+
25
+
26
+ def send(url: str, method: str = "GET", headers: dict[str, str] | None = None, body: str | None = None,
27
+ tls: dict[str, str] | None = None, code: str = "metadata") -> HttpAnswer:
28
+ if not (url.startswith("https://") or url.startswith("http://")):
29
+ raise SamlError(code, f"{url} is not an http(s) URL")
30
+ data = None if body is None else body.encode("utf-8")
31
+ req_headers = dict(headers or {})
32
+ if data is not None:
33
+ req_headers["content-length"] = str(len(data))
34
+ request = urllib.request.Request(url, data=data, headers=req_headers, method=method)
35
+ ctx = None
36
+ temps: list[str] = []
37
+ tls = tls or {}
38
+ if url.startswith("https://"):
39
+ ctx = ssl.create_default_context()
40
+ if tls.get("ca"):
41
+ ctx.load_verify_locations(cadata=tls["ca"])
42
+ if tls.get("cert") and tls.get("key"):
43
+ cert_path = _write_temp(tls["cert"])
44
+ key_path = _write_temp(tls["key"])
45
+ temps.extend([cert_path, key_path])
46
+ ctx.load_cert_chain(cert_path, key_path)
47
+ try:
48
+ try:
49
+ response = urllib.request.urlopen(request, timeout=TIMEOUT_S, context=ctx)
50
+ except urllib.error.HTTPError as err:
51
+ response = err
52
+ except Exception as err:
53
+ raise SamlError(code, f"{url}: {err}") from err
54
+ chunks: list[bytes] = []
55
+ size = 0
56
+ while True:
57
+ part = response.read(65536)
58
+ if not part:
59
+ break
60
+ size += len(part)
61
+ if size > MAX_BYTES:
62
+ raise SamlError(code, f"{url} answered more than {MAX_BYTES} bytes")
63
+ chunks.append(part)
64
+ content_type = ""
65
+ if response.headers is not None:
66
+ content_type = response.headers.get("content-type") or ""
67
+ status = getattr(response, "status", None)
68
+ if status is None:
69
+ status = response.getcode()
70
+ return HttpAnswer(int(status or 0), content_type, b"".join(chunks).decode("utf-8", "replace"))
71
+ except SamlError:
72
+ raise
73
+ except Exception as err:
74
+ message = str(err).lower()
75
+ if "timed out" in message or "timeout" in message:
76
+ raise SamlError(code, f"{url} did not answer within {TIMEOUT_S} s") from err
77
+ raise SamlError(code, f"{url}: {err}") from err
78
+ finally:
79
+ for path in temps:
80
+ try:
81
+ os.unlink(path)
82
+ except OSError:
83
+ pass
84
+
85
+
86
+ def _write_temp(pem: str) -> str:
87
+ handle = tempfile.NamedTemporaryFile("w", delete=False)
88
+ try:
89
+ handle.write(pem)
90
+ handle.close()
91
+ except Exception:
92
+ handle.close()
93
+ raise
94
+ return handle.name
@@ -0,0 +1,129 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """HTTP-Redirect and HTTP-POST encodings (SAML 2.0 Bindings, sections 3.4 and 3.5)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import re
9
+ import zlib
10
+ from urllib.parse import quote, unquote
11
+
12
+ from cryptography.hazmat.primitives import hashes
13
+ from cryptography.hazmat.primitives.asymmetric import padding
14
+
15
+ from saml2_sp.errors import SamlError
16
+ from saml2_sp.signature import RSA_SHA256, SIGNATURE_ALGORITHMS
17
+ from saml2_sp.xml import escape_xml
18
+
19
+ MAX_INFLATED = 1024 * 1024
20
+ _URI_SAFE = "-_.!~*'()"
21
+ _B64 = re.compile(r"^[A-Za-z0-9+/]+={0,2}$")
22
+ _BAD_PCT = re.compile(r"%(?![0-9A-Fa-f]{2})")
23
+
24
+
25
+ def uri_escape(value: str) -> str:
26
+ return quote(value, safe=_URI_SAFE)
27
+
28
+
29
+ def uri_unescape(value: str) -> str:
30
+ text = value.replace("+", "%20")
31
+ if _BAD_PCT.search(text):
32
+ raise SamlError("binding", "the query string is not validly percent-encoded")
33
+ return unquote(text)
34
+
35
+
36
+ def deflate_raw(data: bytes) -> bytes:
37
+ compressor = zlib.compressobj(wbits=-15)
38
+ return compressor.compress(data) + compressor.flush()
39
+
40
+
41
+ def inflate_raw(data: bytes, limit: int = MAX_INFLATED) -> bytes:
42
+ decompressor = zlib.decompressobj(wbits=-15)
43
+ out = decompressor.decompress(data, limit + 1)
44
+ if len(out) > limit or decompressor.unconsumed_tail:
45
+ raise SamlError("binding", "the message inflates past the limit")
46
+ return out + decompressor.flush()
47
+
48
+
49
+ def redirect_query(param: str, xml: str, relay_state: str | None, key) -> str:
50
+ encoded = base64.b64encode(deflate_raw(xml.encode("utf-8"))).decode("ascii")
51
+ parts = [f"{param}={uri_escape(encoded)}"]
52
+ if relay_state is not None:
53
+ parts.append(f"RelayState={uri_escape(relay_state)}")
54
+ parts.append(f"SigAlg={uri_escape(RSA_SHA256)}")
55
+ signed = "&".join(parts)
56
+ signature = base64.b64encode(key.sign(signed.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())).decode("ascii")
57
+ return f"{signed}&Signature={uri_escape(signature)}"
58
+
59
+
60
+ def read_redirect(raw_query: str, param: str, certs: list) -> dict:
61
+ raw: dict[str, str] = {}
62
+ query = raw_query[1:] if raw_query.startswith("?") else raw_query
63
+ for pair in query.split("&"):
64
+ if not pair:
65
+ continue
66
+ eq = pair.find("=")
67
+ name = uri_unescape(pair if eq < 0 else pair[:eq])
68
+ if name in raw:
69
+ raise SamlError("binding", f"the query string carries {name} twice")
70
+ raw[name] = "" if eq < 0 else pair[eq + 1:]
71
+ message = raw.get(param)
72
+ if message is None:
73
+ raise SamlError("binding", f"the query string carries no {param}")
74
+ try:
75
+ xml = inflate_raw(base64.b64decode(uri_unescape(message))).decode("utf-8")
76
+ except SamlError:
77
+ raise
78
+ except Exception as err:
79
+ raise SamlError("binding", f"{param} is not a DEFLATE-encoded message") from err
80
+ relay_state = uri_unescape(raw["RelayState"]) if "RelayState" in raw else None
81
+ if "Signature" not in raw:
82
+ return {"xml": xml, "relay_state": relay_state, "signed": False}
83
+ sig_alg = uri_unescape(raw.get("SigAlg", ""))
84
+ hash_name = SIGNATURE_ALGORITHMS.get(sig_alg)
85
+ if not hash_name:
86
+ raise SamlError("signature", f"the query is signed with {sig_alg or '(no SigAlg)'}, which is refused")
87
+ signed_parts = [f"{param}={message}"]
88
+ if "RelayState" in raw:
89
+ signed_parts.append(f"RelayState={raw['RelayState']}")
90
+ signed_parts.append(f"SigAlg={raw.get('SigAlg', '')}")
91
+ data = "&".join(signed_parts).encode("utf-8")
92
+ try:
93
+ sig_bytes = base64.b64decode(uri_unescape(raw["Signature"]))
94
+ except Exception as err:
95
+ raise SamlError("signature", "the query string signature does not verify against the identity provider's certificate") from err
96
+ hash_alg = {"sha256": hashes.SHA256(), "sha384": hashes.SHA384(), "sha512": hashes.SHA512()}[hash_name]
97
+ for cert in certs:
98
+ try:
99
+ cert.public_key().verify(sig_bytes, data, padding.PKCS1v15(), hash_alg)
100
+ return {"xml": xml, "relay_state": relay_state, "signed": True}
101
+ except Exception:
102
+ continue
103
+ raise SamlError("signature", "the query string signature does not verify against the identity provider's certificate")
104
+
105
+
106
+ def read_post(value: str, param: str) -> str:
107
+ compact = re.sub(r"\s+", "", value)
108
+ if not compact or not _B64.match(compact):
109
+ raise SamlError("binding", f"{param} is not base64")
110
+ return base64.b64decode(compact).decode("utf-8")
111
+
112
+
113
+ def post_form_html(action: str, fields: dict[str, str], nonce: str | None = None, title: str | None = None) -> str:
114
+ inputs = "\n ".join(
115
+ f'<input type="hidden" name="{escape_xml(name)}" value="{escape_xml(value)}">' for name, value in fields.items()
116
+ )
117
+ script = f'\n <script nonce="{escape_xml(nonce)}">document.forms[0].submit();</script>' if nonce else ""
118
+ return f"""<!doctype html>
119
+ <html lang="en">
120
+ <head><meta charset="utf-8"><title>{escape_xml(title or "Signing in")}</title></head>
121
+ <body>
122
+ <form method="post" action="{escape_xml(action)}">
123
+ {inputs}
124
+ <noscript><p>Your browser is not running scripts. Press Continue to go on.</p></noscript>
125
+ <button type="submit">Continue</button>
126
+ </form>{script}
127
+ </body>
128
+ </html>
129
+ """