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 +35 -0
- saml2_sp/artifact.py +86 -0
- saml2_sp/backchannel.py +94 -0
- saml2_sp/bindings.py +129 -0
- saml2_sp/cli.py +147 -0
- saml2_sp/config.py +156 -0
- saml2_sp/encryption.py +120 -0
- saml2_sp/errors.py +28 -0
- saml2_sp/metadata.py +116 -0
- saml2_sp/replay.py +38 -0
- saml2_sp/response.py +261 -0
- saml2_sp/service_provider.py +207 -0
- saml2_sp/signature.py +251 -0
- saml2_sp/ssf/__init__.py +20 -0
- saml2_sp/ssf/events.py +92 -0
- saml2_sp/ssf/jws.py +122 -0
- saml2_sp/ssf/receiver.py +307 -0
- saml2_sp/ssf/session_index.py +88 -0
- saml2_sp/x509util.py +96 -0
- saml2_sp/xml.py +120 -0
- saml2_sp-0.1.0.dist-info/METADATA +109 -0
- saml2_sp-0.1.0.dist-info/RECORD +26 -0
- saml2_sp-0.1.0.dist-info/WHEEL +5 -0
- saml2_sp-0.1.0.dist-info/entry_points.txt +2 -0
- saml2_sp-0.1.0.dist-info/licenses/LICENSE +21 -0
- saml2_sp-0.1.0.dist-info/top_level.txt +1 -0
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]}
|
saml2_sp/backchannel.py
ADDED
|
@@ -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)))
|