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.
- saml2_sp-0.1.0/LICENSE +21 -0
- saml2_sp-0.1.0/PKG-INFO +109 -0
- saml2_sp-0.1.0/README.md +91 -0
- saml2_sp-0.1.0/pyproject.toml +35 -0
- saml2_sp-0.1.0/setup.cfg +4 -0
- saml2_sp-0.1.0/src/saml2_sp/__init__.py +35 -0
- saml2_sp-0.1.0/src/saml2_sp/artifact.py +86 -0
- saml2_sp-0.1.0/src/saml2_sp/backchannel.py +94 -0
- saml2_sp-0.1.0/src/saml2_sp/bindings.py +129 -0
- saml2_sp-0.1.0/src/saml2_sp/cli.py +147 -0
- saml2_sp-0.1.0/src/saml2_sp/config.py +156 -0
- saml2_sp-0.1.0/src/saml2_sp/encryption.py +120 -0
- saml2_sp-0.1.0/src/saml2_sp/errors.py +28 -0
- saml2_sp-0.1.0/src/saml2_sp/metadata.py +116 -0
- saml2_sp-0.1.0/src/saml2_sp/replay.py +38 -0
- saml2_sp-0.1.0/src/saml2_sp/response.py +261 -0
- saml2_sp-0.1.0/src/saml2_sp/service_provider.py +207 -0
- saml2_sp-0.1.0/src/saml2_sp/signature.py +251 -0
- saml2_sp-0.1.0/src/saml2_sp/ssf/__init__.py +20 -0
- saml2_sp-0.1.0/src/saml2_sp/ssf/events.py +92 -0
- saml2_sp-0.1.0/src/saml2_sp/ssf/jws.py +122 -0
- saml2_sp-0.1.0/src/saml2_sp/ssf/receiver.py +307 -0
- saml2_sp-0.1.0/src/saml2_sp/ssf/session_index.py +88 -0
- saml2_sp-0.1.0/src/saml2_sp/x509util.py +96 -0
- saml2_sp-0.1.0/src/saml2_sp/xml.py +120 -0
- saml2_sp-0.1.0/src/saml2_sp.egg-info/PKG-INFO +109 -0
- saml2_sp-0.1.0/src/saml2_sp.egg-info/SOURCES.txt +33 -0
- saml2_sp-0.1.0/src/saml2_sp.egg-info/dependency_links.txt +1 -0
- saml2_sp-0.1.0/src/saml2_sp.egg-info/entry_points.txt +2 -0
- saml2_sp-0.1.0/src/saml2_sp.egg-info/requires.txt +5 -0
- saml2_sp-0.1.0/src/saml2_sp.egg-info/top_level.txt +1 -0
- saml2_sp-0.1.0/tests/test_configure.py +177 -0
- saml2_sp-0.1.0/tests/test_sp.py +400 -0
- saml2_sp-0.1.0/tests/test_ssf.py +319 -0
- 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.
|
saml2_sp-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
saml2_sp-0.1.0/README.md
ADDED
|
@@ -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"
|
saml2_sp-0.1.0/setup.cfg
ADDED
|
@@ -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
|
+
"""
|