snapkitty
cryptography
python
xml
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,
        )