File size: 3,619 Bytes
ecf9cb9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | """
saml/sp/authn_request.py
SAML AuthnRequest builder: XML construction, C14N, RSA-SHA256 signing,
Base64 + deflate encoding for HTTP-Redirect binding.
"""
import base64
import uuid
import zlib
from dataclasses import dataclass
from datetime import datetime, timezone
from xml.etree import ElementTree as ET
from crypto.xml_dsig import sign_element, exclusive_c14n
SAML_NS = "urn:oasis:names:tc:SAML:2.0:assertion"
SAMLP_NS = "urn:oasis:names:tc:SAML:2.0:protocol"
_SA = "{%s}" % SAML_NS
_SAP = "{%s}" % SAMLP_NS
REDIRECT_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
@dataclass
class AuthnRequestConfig:
sp_entity_id: str
idp_sso_url: str
acs_url: str
private_key: object # RSAPrivateKey
certificate: object # x509.Certificate
@dataclass
class SignedRequest:
xml_element: ET.Element
redirect_url: str
request_id: str
post_value: str
class AuthnRequestBuilder:
def __init__(self, config: AuthnRequestConfig):
self.config = config
def build(self) -> ET.Element:
request_id = '_' + uuid.uuid4().hex
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
root = ET.Element(_SAP + 'AuthnRequest')
root.set('ID', request_id)
root.set('Version', '2.0')
root.set('IssueInstant', now)
root.set('Destination', self.config.idp_sso_url)
root.set('ProtocolBinding', POST_BINDING)
root.set('AssertionConsumerServiceURL', self.config.acs_url)
issuer = ET.SubElement(root, _SA + 'Issuer')
issuer.text = self.config.sp_entity_id
nameid_policy = ET.SubElement(root, _SAP + 'NameIDPolicy')
nameid_policy.set('AllowCreate', 'true')
nameid_policy.set('Format', 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient')
return root
def sign(self, element: ET.Element) -> ET.Element:
request_id = element.get('ID', '_unknown')
sign_element(element, self.config.private_key, self.config.certificate, request_id)
return element
def encode_redirect(self, element: ET.Element) -> str:
"""Deflate + Base64 + URL-encode for HTTP-Redirect binding SAMLRequest param."""
xml_bytes = ET.tostring(element, encoding='unicode').encode('utf-8')
compressed = zlib.compress(xml_bytes)[2:-4] # raw deflate (strip zlib header/trailer)
b64 = base64.b64encode(compressed).decode('ascii')
from urllib.parse import quote
return quote(b64)
def encode_post(self, element: ET.Element) -> str:
"""Base64-encode for HTTP-POST binding SAMLRequest field."""
xml_bytes = ET.tostring(element, encoding='unicode').encode('utf-8')
return base64.b64encode(xml_bytes).decode('ascii')
def build_and_sign(self) -> SignedRequest:
element = self.build()
request_id = element.get('ID', '_unknown')
self.sign(element)
redirect_encoded = self.encode_redirect(element)
post_encoded = self.encode_post(element)
redirect_url = (
f"{self.config.idp_sso_url}"
f"?SAMLRequest={redirect_encoded}"
)
return SignedRequest(
xml_element=element,
redirect_url=redirect_url,
request_id=request_id,
post_value=post_encoded,
)
|