saml2-sp 0.1.0__py3-none-any.whl

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/__init__.py 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
+ ]
saml2_sp/artifact.py ADDED
@@ -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
saml2_sp/bindings.py ADDED
@@ -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
+ """
saml2_sp/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """saml2-sp's command line: a key pair, and this service provider's metadata."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ from saml2_sp.config import resolve_config
12
+ from saml2_sp.service_provider import ServiceProvider
13
+ from saml2_sp.x509util import create_self_signed_key_pair
14
+
15
+ USAGE = """Usage:
16
+ saml2-sp generate-keys --config <saml.json> [--cn <name>] [--days <n>] [--bits <n>] [--force]
17
+ saml2-sp generate-keys --key <file> --cert <file> [--cn <name>] [--days <n>] [--bits <n>] [--force]
18
+ A new RSA key and a self-signed certificate, written where the configuration's privateKey and
19
+ certificate point (or to --key and --cert). Refuses to replace existing files without --force.
20
+ The common name defaults to the host of the configuration's entityId.
21
+
22
+ saml2-sp metadata --config <saml.json> [--out <file>] [--entity-id <url>] [--acs-url <url>]
23
+ This service provider's SAML metadata, from the configuration and its certificate. Written to
24
+ --out, or to standard output. --entity-id and --acs-url replace the file's values for this run."""
25
+
26
+
27
+ def parse_args(argv: list[str]) -> tuple[str | None, dict]:
28
+ command = argv[0] if argv else None
29
+ rest = argv[1:]
30
+ args: dict = {}
31
+ index = 0
32
+ while index < len(rest):
33
+ item = rest[index]
34
+ if not item.startswith("--"):
35
+ raise SystemExit(f"unexpected argument {item}\n\n{USAGE}")
36
+ key = item[2:]
37
+ if key in ("force", "help"):
38
+ args[key] = True
39
+ else:
40
+ if index + 1 >= len(rest) or rest[index + 1].startswith("--"):
41
+ raise SystemExit(f"--{key} needs a value\n\n{USAGE}")
42
+ args[key] = rest[index + 1]
43
+ index += 1
44
+ index += 1
45
+ return command, args
46
+
47
+
48
+ def _str(value) -> str | None:
49
+ return value if isinstance(value, str) else None
50
+
51
+
52
+ def key_paths(args: dict) -> dict:
53
+ config_path = _str(args.get("config"))
54
+ key = _str(args.get("key"))
55
+ cert = _str(args.get("cert"))
56
+ common_name = _str(args.get("cn"))
57
+ if config_path:
58
+ with open(config_path, encoding="utf-8") as handle:
59
+ raw = json.load(handle)
60
+ base = os.path.dirname(os.path.abspath(config_path))
61
+
62
+ def at(path: str | None, what: str) -> str:
63
+ if not path or "-----BEGIN " in path:
64
+ raise SystemExit(f"{config_path}'s {what} must be a file path for generate-keys to write to")
65
+ value = path[5:] if path.startswith("file:") else path
66
+ if value.startswith("env:"):
67
+ raise SystemExit(f"{config_path}'s {what} must be a file path for generate-keys to write to")
68
+ return value if os.path.isabs(value) else os.path.abspath(os.path.join(base, value))
69
+
70
+ key = key or at(raw.get("privateKey"), "privateKey")
71
+ cert = cert or at(raw.get("certificate"), "certificate")
72
+ if not common_name and raw.get("entityId"):
73
+ from urllib.parse import urlparse
74
+ try:
75
+ common_name = urlparse(raw["entityId"]).hostname or raw["entityId"]
76
+ except Exception:
77
+ common_name = raw["entityId"]
78
+ if not key or not cert:
79
+ raise SystemExit(f"generate-keys needs --config, or --key and --cert\n\n{USAGE}")
80
+ return {"key": os.path.abspath(key), "cert": os.path.abspath(cert), "common_name": common_name or "saml2-sp service provider"}
81
+
82
+
83
+ def _write(path: str, text: str, mode: int) -> None:
84
+ os.makedirs(os.path.dirname(path), exist_ok=True)
85
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
86
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
87
+ handle.write(text)
88
+
89
+
90
+ def main(argv: list[str] | None = None) -> None:
91
+ command, args = parse_args(list(sys.argv[1:] if argv is None else argv))
92
+ if not command or args.get("help") or command == "help":
93
+ print(USAGE)
94
+ return
95
+ if command == "generate-keys":
96
+ paths = key_paths(args)
97
+ if not args.get("force") and (os.path.exists(paths["key"]) or os.path.exists(paths["cert"])):
98
+ which = paths["key"] if os.path.exists(paths["key"]) else paths["cert"]
99
+ raise SystemExit(f"{which} already exists; pass --force to replace the key pair.\n"
100
+ "Replacing it means giving the identity provider the new metadata before the next sign-in.")
101
+ days = int(args["days"]) if _str(args.get("days")) else None
102
+ bits = int(args["bits"]) if _str(args.get("bits")) else None
103
+ if days is not None and days <= 0:
104
+ raise SystemExit("--days must be a positive whole number")
105
+ if bits is not None and bits not in (2048, 3072, 4096):
106
+ raise SystemExit("--bits must be 2048, 3072 or 4096")
107
+ pair = create_self_signed_key_pair(paths["common_name"], days or 1095, bits or 3072)
108
+ _write(paths["key"], pair.private_key_pem, 0o600)
109
+ _write(paths["cert"], pair.certificate_pem, 0o644)
110
+ print(f"Wrote the private key {paths['key']} (mode 600)")
111
+ print(f"Wrote the certificate {paths['cert']}")
112
+ print(f" subject CN={paths['common_name']}")
113
+ print(f" valid to {pair.valid_to}")
114
+ print(f" sha256 {pair.fingerprint256}")
115
+ return
116
+ if command == "metadata":
117
+ config_path = _str(args.get("config"))
118
+ if not config_path:
119
+ raise SystemExit(f"metadata needs --config\n\n{USAGE}")
120
+ with open(config_path, encoding="utf-8") as handle:
121
+ raw = json.load(handle)
122
+ raw.pop("idpCa", None)
123
+ if _str(args.get("entity-id")):
124
+ raw["entityId"] = args["entity-id"]
125
+ if _str(args.get("acs-url")):
126
+ raw["acsUrl"] = args["acs-url"]
127
+ xml = ServiceProvider(resolve_config(raw, os.path.dirname(os.path.abspath(config_path))), None).metadata()
128
+ target = _str(args.get("out"))
129
+ if target:
130
+ os.makedirs(os.path.dirname(os.path.abspath(target)) or ".", exist_ok=True)
131
+ with open(target, "w", encoding="utf-8") as handle:
132
+ handle.write(xml)
133
+ print(f"Wrote the service provider metadata to {os.path.abspath(target)}")
134
+ else:
135
+ sys.stdout.write(xml)
136
+ return
137
+ raise SystemExit(f"unknown command {command}\n\n{USAGE}")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ try:
142
+ main()
143
+ except SystemExit as err:
144
+ if err.code not in (None, 0):
145
+ sys.stderr.write(f"{err.code if isinstance(err.code, str) else err}\n")
146
+ raise SystemExit(1) from err
147
+ raise
saml2_sp/config.py ADDED
@@ -0,0 +1,156 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """The configuration: seven settings, four of them with defaults. The rest is in the identity provider's metadata."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import math
9
+ import os
10
+ import re
11
+
12
+ from cryptography.hazmat.primitives.asymmetric import rsa
13
+
14
+ from saml2_sp.errors import SamlError
15
+ from saml2_sp.x509util import certificate_pem, load_certificate, load_private_key, private_key_pem
16
+
17
+ _REQUEST = ("redirect", "post")
18
+ _RESPONSE = ("post", "redirect", "artifact")
19
+
20
+
21
+ class ResolvedConfig:
22
+ """The configuration with its files read and its defaults filled in.
23
+
24
+ ``signals`` is present only when CAEP and RISC are off. Omitted means on: test ``signals not in config``.
25
+ """
26
+
27
+ def __init__(self, **fields: object) -> None:
28
+ self.__dict__.update(fields)
29
+
30
+ def __contains__(self, key: object) -> bool:
31
+ return key in self.__dict__
32
+
33
+
34
+ def _expand(value: str) -> str:
35
+ seen = 0
36
+ while value.startswith("env:") or value.startswith("file:"):
37
+ seen += 1
38
+ if seen > 4:
39
+ raise SamlError("config", f"the placeholder {value} is nested too deeply")
40
+ if value.startswith("env:"):
41
+ name = value[4:]
42
+ if not name or name not in os.environ:
43
+ raise SamlError("config", f"environment variable {name or value} is not set")
44
+ value = os.environ[name]
45
+ continue
46
+ value = value[5:]
47
+ return value
48
+
49
+
50
+ def _looks_like_pem(value: str) -> bool:
51
+ return "-----BEGIN " in value
52
+
53
+
54
+ def material(value: str, base_dir: str, what: str) -> str:
55
+ value = _expand(value)
56
+ if _looks_like_pem(value):
57
+ return value
58
+ path = value if os.path.isabs(value) else os.path.abspath(os.path.join(base_dir, value))
59
+ try:
60
+ with open(path, encoding="utf-8") as handle:
61
+ return handle.read()
62
+ except OSError as err:
63
+ raise SamlError("config", f"{what} is neither a PEM nor a readable file ({path})") from err
64
+
65
+
66
+ def _absolute_url(value: object, what: str) -> str:
67
+ if not isinstance(value, str) or not value.strip():
68
+ raise SamlError("config", f"{what} is required")
69
+ if not re.match(r"https?://", value):
70
+ raise SamlError("config", f"{what} must be an absolute http(s) URL, not {value}")
71
+ return value
72
+
73
+
74
+ def _one_of(value: object, allowed: tuple[str, ...], default: str, what: str) -> str:
75
+ if value is None or value == "":
76
+ return default
77
+ if value not in allowed:
78
+ raise SamlError("config", f"{what} must be one of {', '.join(allowed)}, not {value}")
79
+ return str(value)
80
+
81
+
82
+ def _number(value: object) -> bool:
83
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
84
+
85
+
86
+ def resolve_config(config: dict, base_dir: str | None = None) -> ResolvedConfig:
87
+ if not isinstance(config, dict):
88
+ raise SamlError("config", "the configuration must be an object")
89
+ base = base_dir or os.getcwd()
90
+ entity_id = config.get("entityId")
91
+ if not isinstance(entity_id, str) or not entity_id.strip():
92
+ raise SamlError("config", "entityId is required")
93
+ acs_url = _absolute_url(config.get("acsUrl"), "acsUrl")
94
+ if not isinstance(config.get("privateKey"), str) or not isinstance(config.get("certificate"), str):
95
+ raise SamlError("config", "privateKey and certificate are required: every AuthnRequest is signed")
96
+ try:
97
+ key_pem = material(config["privateKey"], base, "privateKey")
98
+ cert_pem = material(config["certificate"], base, "certificate")
99
+ private_key = load_private_key(key_pem)
100
+ certificate = load_certificate(cert_pem)
101
+ except SamlError:
102
+ raise
103
+ except Exception as err:
104
+ raise SamlError("config", f"the private key or certificate cannot be read: {err}") from err
105
+ if not isinstance(private_key, rsa.RSAPrivateKey):
106
+ kind = "ec" if private_key.__class__.__name__.lower().startswith("ec") else private_key.__class__.__name__
107
+ raise SamlError("config", f"the private key is {kind}; an RSA key is required")
108
+ if private_key.public_key().public_numbers() != certificate.public_key().public_numbers():
109
+ raise SamlError("config", "the certificate is not for the private key")
110
+ idp_metadata = config.get("idpMetadata")
111
+ if not isinstance(idp_metadata, str) or not idp_metadata.strip():
112
+ raise SamlError("config", "idpMetadata is required")
113
+ idp_metadata = _expand(idp_metadata.strip())
114
+ if not re.match(r"https?://", idp_metadata) and not idp_metadata.startswith("<"):
115
+ idp_metadata = idp_metadata if os.path.isabs(idp_metadata) else os.path.abspath(os.path.join(base, idp_metadata))
116
+ skew = config["clockSkewSeconds"] if "clockSkewSeconds" in config and config["clockSkewSeconds"] is not None else 60
117
+ if not _number(skew) or skew < 0 or skew > 600:
118
+ raise SamlError("config", "clockSkewSeconds must be a number from 0 to 600")
119
+ if "signals" in config and not isinstance(config["signals"], bool):
120
+ raise SamlError("config", "signals must be true or false")
121
+ fields: dict[str, object] = {
122
+ "entity_id": entity_id,
123
+ "acs_url": acs_url,
124
+ "private_key": private_key,
125
+ "private_key_pem": private_key_pem(private_key),
126
+ "certificate": certificate,
127
+ "certificate_pem": certificate_pem(certificate),
128
+ "idp_metadata": idp_metadata,
129
+ "request_binding": _one_of(config.get("requestBinding"), _REQUEST, "redirect", "requestBinding"),
130
+ "response_binding": _one_of(config.get("responseBinding"), _RESPONSE, "post", "responseBinding"),
131
+ "clock_skew_ms": int(skew) * 1000,
132
+ "want_assertions_signed": True if config.get("wantAssertionsSigned") is None else config.get("wantAssertionsSigned"),
133
+ "want_assertions_encrypted": False if config.get("wantAssertionsEncrypted") is None else config.get("wantAssertionsEncrypted"),
134
+ "allow_unsolicited": False if config.get("allowUnsolicited") is None else config.get("allowUnsolicited"),
135
+ }
136
+ if config.get("signals") is False:
137
+ fields["signals"] = False
138
+ if config.get("idpCa"):
139
+ fields["idp_ca"] = material(config["idpCa"], base, "idpCa")
140
+ if config.get("idpEntityId"):
141
+ fields["idp_entity_id"] = config["idpEntityId"]
142
+ if config.get("nameIdFormat"):
143
+ fields["name_id_format"] = config["nameIdFormat"]
144
+ return ResolvedConfig(**fields)
145
+
146
+
147
+ def read_config_file(path: str, overrides: dict | None = None) -> ResolvedConfig:
148
+ try:
149
+ with open(path, encoding="utf-8") as handle:
150
+ parsed = json.load(handle)
151
+ except Exception as err:
152
+ raise SamlError("config", f"{path} cannot be read as JSON: {err}") from err
153
+ defined = {k: v for k, v in (overrides or {}).items() if v is not None}
154
+ if not isinstance(parsed, dict):
155
+ raise SamlError("config", "the configuration must be an object")
156
+ return resolve_config({**parsed, **defined}, os.path.dirname(os.path.abspath(path)))