├── .gitattributes ├── setup.cfg ├── saml ├── schema │ ├── meta.py │ ├── utils.py │ ├── __init__.py │ ├── types.py │ ├── base.py │ ├── saml.py │ └── samlp.py ├── _version.py ├── __init__.py ├── client.py └── signature.py ├── docs ├── creating_documents.rst ├── signing_documents.rst ├── index.rst ├── installation.rst ├── contributing.rst ├── conf.py └── Makefile ├── .gitignore ├── .editorconfig ├── requirements.txt ├── tests ├── test_client.py ├── rsapub.pem ├── artifact-resolve-simple.xml ├── authentication-request-simple.xml ├── logout-response-simple.xml ├── logout-request-simple.xml ├── test_utils.py ├── artifact-response-simple.xml ├── artifact-resolve-signed.xml ├── assertion-simple.xml ├── logout-response-signed.xml ├── authentication-request-signed.xml ├── logout-request-signed.xml ├── rsakey.pem ├── artifact-response-signed.xml ├── assertion-signed.xml ├── response-simple.xml ├── response-signed.xml ├── rsacert.pem └── test_schema.py ├── .travis.yml ├── LICENSE ├── setup.py └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -rx -s 3 | -------------------------------------------------------------------------------- /saml/schema/meta.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | version = '2.0' 3 | -------------------------------------------------------------------------------- /docs/creating_documents.rst: -------------------------------------------------------------------------------- 1 | Creating Documents 2 | ================== 3 | 4 | .. automodule:: saml.schema 5 | -------------------------------------------------------------------------------- /docs/signing_documents.rst: -------------------------------------------------------------------------------- 1 | Signing Documents 2 | ================= 3 | 4 | .. automodule:: saml.signature 5 | -------------------------------------------------------------------------------- /saml/_version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version_info__ = (0, 9, 0) 3 | __version__ = '.'.join(map(str, __version_info__)) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dot files 2 | .* 3 | !.editorconfig 4 | !.gitattributes 5 | !.gitignore 6 | !.travis* 7 | 8 | # Python 9 | /dist 10 | /build 11 | *.pyc 12 | *.pyo 13 | *.egg-info 14 | 15 | # Sphinx 16 | /docs/_build 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.2 2 | Babel==1.3 3 | docutils==0.12 4 | Jinja2==2.7.3 5 | lxml==3.4.2 6 | MarkupSafe==0.23 7 | Pygments==2.0.2 8 | python-dateutil==2.4.1 9 | pytz==2014.10 10 | six==1.9.0 11 | snowballstemmer==1.2.0 12 | Sphinx==1.3.1 13 | sphinx-rtd-theme==0.1.7 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Python-SAML Documentation 2 | ========================= 3 | 4 | .. automodule:: saml 5 | 6 | Contents: 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | installation 12 | creating_documents 13 | signing_documents 14 | contributing 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | 23 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from saml import client 2 | from test_schema import build_authentication_request_simple 3 | from six.moves.urllib.parse import quote_plus 4 | 5 | 6 | def test_relay_state(): 7 | target = build_authentication_request_simple() 8 | state = 'http://localhost:8080/' 9 | uri, _ = client.send('http://localhost', target.serialize(), state) 10 | 11 | relay_state_part = 'RelayState=%s' % quote_plus(state) 12 | assert relay_state_part in uri 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | - '3.3' 5 | - '3.4' 6 | 7 | before_install: 8 | - 'travis_retry sudo apt-get update' 9 | - 'travis_retry sudo apt-get install python-dev libxml2-dev libxmlsec1-dev' 10 | - 'travis_retry pip install Cython --use-mirrors' 11 | 12 | install: 13 | - 'travis_retry pip install -e ".[test]"' 14 | - 'travis_retry pip install coveralls' 15 | 16 | script: 'py.test --pep8 --flakes --cov saml' 17 | 18 | after_success: 'coveralls' 19 | -------------------------------------------------------------------------------- /tests/rsapub.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl7j+tD+DNXgWiQTsK2GM 3 | v8RfAIFKRebZzeniPJc7Ra2q5o0Ld3EHAU98+X3iGardkVn08c89unhGlhGctltG 4 | OXNVI6r3ngBc5elJ7DucP4SZOpCt335khsYmcs4xCHl+ExW45b/WVgKNYCFMJxhk 5 | +/tVcPYzvS9VcNVefpmupOCqRUcTqDDVoIqdzCDs5I5RyVTFfz5mLXS/o3r48+yU 6 | Vzm0rAB1YmFUtNDgUob4XnfsUEOc0rqnjGJavLL+88xifiNga8dRSTd4fiUVMKv6 7 | tK4ljyL8o0h/8gqKbuD+jfAB7cYzzGuh/aaA7waMr/ZAOo5CFCBhEh/j/AWxBdVl 8 | wwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /tests/artifact-resolve-simple.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | https://idp.example.org/SAML2 9 | AAQAAMh48/1oXIM+sDo7Dh2qMp1HM4IF5DaRNmDj6RdUmllwn9jJHyEgIi8= 10 | 11 | -------------------------------------------------------------------------------- /tests/authentication-request-simple.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | https://sp.example.com/SAML2 11 | 14 | 15 | -------------------------------------------------------------------------------- /saml/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | A python interface to produce and consume Security Assertion Markup 4 | Language (SAML) v2.0 messages. 5 | 6 | See: https://www.oasis-open.org/standards#samlv2.0 7 | """ 8 | # Version of the library. 9 | from ._version import __version__, __version_info__ # noqa 10 | 11 | # Version of the SAML standard supported. 12 | from .schema import VERSION as SAML_VERSION 13 | 14 | from .signature import sign, verify 15 | from . import client 16 | 17 | VERSION = __version__ 18 | 19 | __all__ = [ 20 | 'VERSION', 21 | 'SAML_VERSION', 22 | 'sign', 23 | 'verify', 24 | 'client' 25 | ] 26 | -------------------------------------------------------------------------------- /saml/schema/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | 5 | def _upcase_first_letter(s): 6 | return s[0].upper() + s[1:] 7 | 8 | 9 | def pascalize(name): 10 | name = _upcase_first_letter(name.strip()) 11 | pattern = r'[-_\s]+(.)?' 12 | name = re.sub(pattern, lambda m: m.groups()[0].upper() if m else '', name) 13 | return name 14 | 15 | 16 | class classproperty(object): 17 | """Declares a read-only `property` that acts on the class object. 18 | """ 19 | 20 | def __init__(self, getter): 21 | self.getter = getter 22 | 23 | def __get__(self, obj, cls): 24 | return self.getter(cls) 25 | -------------------------------------------------------------------------------- /tests/logout-response-simple.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | https://idp.example.org/SAML2 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/logout-request-simple.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | myhost 10 | myemail@mydomain.com 12 | _0628125f-7f95-42cc-ad8e-fde86ae90bbe 13 | 14 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from saml.schema import utils 2 | 3 | 4 | class TestPascalize: 5 | 6 | def test_normal(self): 7 | text = 'something' 8 | 9 | assert utils.pascalize(text) == 'Something' 10 | 11 | def test_underscore(self): 12 | text = 'some_thing' 13 | 14 | assert utils.pascalize(text) == 'SomeThing' 15 | 16 | def test_already(self): 17 | text = 'someThing' 18 | 19 | assert utils.pascalize(text) == 'SomeThing' 20 | 21 | def test_dash(self): 22 | text = 'some-thing' 23 | 24 | assert utils.pascalize(text) == 'SomeThing' 25 | 26 | def test_null(self): 27 | text = 'SomeThing' 28 | 29 | assert utils.pascalize(text) == 'SomeThing' 30 | 31 | def test_pascal(self): 32 | text = 'Some_thing' 33 | 34 | assert utils.pascalize(text) == 'SomeThing' 35 | -------------------------------------------------------------------------------- /saml/schema/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Create XML documents in accordance with the SAML 2.0 specification 5 | 6 | AuthnRequest 7 | ------------ 8 | .. autoclass:: saml.schema.AuthenticationRequest 9 | 10 | Response 11 | -------- 12 | .. autoclass:: saml.schema.Response 13 | 14 | LogoutRequest 15 | ------------- 16 | .. autoclass:: saml.schema.LogoutRequest 17 | 18 | LogoutResponse 19 | -------------- 20 | .. autoclass:: saml.schema.LogoutResponse 21 | """ 22 | 23 | from .meta import version as VERSION # noqa 24 | from .saml import * # noqa 25 | from .samlp import * # noqa 26 | 27 | 28 | def deserialize(xml): 29 | from .base import _element_registry 30 | 31 | # Resolve the xml into an element. 32 | element = _element_registry.get(xml.tag) 33 | if not element: 34 | return None 35 | 36 | # Deserialize the xml and return. 37 | return element.deserialize(xml) 38 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Supported platforms 5 | ------------------- 6 | - Python 2.7 7 | - Python 3.3 8 | - Python 3.4 9 | 10 | Dependencies 11 | ------------ 12 | 13 | In order to sign and verify signatures, `libxml2` and `libxmlsec` are required. 14 | 15 | Linux 16 | :: 17 | apt-get install libxml2-dev libxmlsec1-dev 18 | 19 | Mac 20 | :: 21 | brew install libxml2 libxmlsec1 22 | 23 | 24 | Installing an official release 25 | ------------------------------ 26 | 27 | The most recent release is available from PyPI 28 | :: 29 | pip install saml 30 | 31 | Installing the development version 32 | ---------------------------------- 33 | 34 | 1. Clone the **python-saml** repository 35 | :: 36 | git clone git://github.com/mehcode/python-saml.git 37 | 38 | 2. Change into the project directory 39 | :: 40 | cd python-saml 41 | 42 | 3. Install the project and all its dependencies using `pip` 43 | :: 44 | pip install . 45 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Setting up your environment 5 | --------------------------- 6 | 7 | 1. Fork the repository 8 | 2. Clone your fork 9 | 3. `Create a virtual environment `_. 10 | 4. Install **python-saml** in development mode with testing enabled. This will download all dependencies required for running the unit tests. 11 | :: 12 | pip install -e ".[test]" 13 | 5. Make changes with tests and documentation 14 | 6. Open a pull request 15 | 16 | Running the tests 17 | ----------------- 18 | 19 | Tests are run with `py.test`. 20 | :: 21 | py.test --pep8 --flakes --cov saml 22 | 23 | Testing documentation changes 24 | ----------------------------- 25 | 26 | Documentation is handled with `Sphinx `_. Use the `make html` command in the `docs` directory to build an HTML preview of the documentation. 27 | :: 28 | cd docs 29 | make html 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ryan Leckey 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 furnished 10 | 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. 22 | -------------------------------------------------------------------------------- /tests/artifact-response-simple.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 20 | https://sp.example.com/SAML2 21 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/artifact-resolve-signed.xml: -------------------------------------------------------------------------------- 1 | https://idp.example.org/SAML2 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | r6jFOa6bsPAjD3IZGidgANuw3p4= 11 | 12 | 13 | HOKsFmgbRWXnj9oB9qNxA7hBMm0edNV3MBldAnkVONrla9ybOvqO1BPFc/1CRcYV 14 | vaX+BrNhDi6A1pFJ/+Wo0HdijqyhM6zRlc3PHslloB5vyxIExrA/4ujzMfDJxqe8 15 | 1P54fqijWRkwT26cjqHjrgvhJir9YmjkDxitVOcnHR8ggupT8gluygVlHHPiRfJI 16 | TWz1Kc0/9kghuneXbxXZbeK9DvGi+pfpf3Tp3ES7TORwT+MF/GRbXecZuuqurD6V 17 | ZT1hJySA5C4surumKCGbFf6yT9ySAM8fuWOSXAp+16mF/p9/bx+xNq4jwdMloXuv 18 | 2z2PumKL/zpR4mNaw3f19A== 19 | AAQAAMh48/1oXIM+sDo7Dh2qMp1HM4IF5DaRNmDj6RdUmllwn9jJHyEgIi8= 20 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | import os 5 | 6 | import sphinx_rtd_theme 7 | 8 | sys.path.insert(0, os.path.abspath('../')) 9 | 10 | from saml import __version__ # noqa 11 | 12 | extensions = [ 13 | 'sphinx.ext.autodoc', 14 | 'sphinx.ext.coverage', 15 | 'sphinx.ext.ifconfig', 16 | 'sphinx.ext.viewcode', 17 | ] 18 | 19 | templates_path = ['_templates'] 20 | source_suffix = ['.rst'] 21 | master_doc = 'index' 22 | 23 | project = u'python-saml' 24 | copyright = u'2015, Ryan Leckey' 25 | author = u'Ryan Leckey' 26 | 27 | version = __version__ 28 | release = version 29 | 30 | exclude_patterns = ['_build'] 31 | pygments_style = 'sphinx' 32 | todo_include_todos = False 33 | html_theme = "sphinx_rtd_theme" 34 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 35 | html_static_path = ['_static'] 36 | htmlhelp_basename = 'python-samldoc' 37 | latex_elements = {} 38 | 39 | latex_documents = [ 40 | (master_doc, 'python-saml.tex', u'python-saml Documentation', 41 | u'Ryan Leckey', 'manual'), 42 | ] 43 | 44 | man_pages = [ 45 | (master_doc, 'python-saml', u'python-saml Documentation', 46 | [author], 1) 47 | ] 48 | 49 | texinfo_documents = [ 50 | (master_doc, 'python-saml', u'python-saml Documentation', 51 | author, 'python-saml', 'One line description of project.', 52 | 'Miscellaneous'), 53 | ] 54 | -------------------------------------------------------------------------------- /tests/assertion-simple.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://idp.example.org/SAML2 4 | 5 | 3f7b3dcf-1674-4ecd-92c8-1544f346baf8 6 | 7 | 8 | 9 | 10 | 11 | 12 | https://sp.example.com/SAML2 13 | 14 | 15 | 16 | 17 | urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/logout-response-signed.xml: -------------------------------------------------------------------------------- 1 | https://idp.example.org/SAML2 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 7rz6jS2X2irRen/0qthQdjuPetw= 11 | 12 | 13 | Ra7hDm4vCa655p2TXP5LRXcNfvh0przg1c2pQHFS+b4P8uu5cplzF87dMzxQy6hc 14 | ZJKZTusn1icpczzRmjHh4KupXZJ4Qh4XABXdy9bdIh3e8crdkxq55jRtt9kBQNDb 15 | fOqH4GJpdoRMPQ2MzHU3lZg8D/zWhrO6Nat5MXT0EFfHiaNftXEpzTxdR9sk6lTA 16 | 9A2BfsX0/F1CULwByayAkkfgGpk8pPbPBv5+GFTSNenJJoXRhnLlsaV5EjKNc7JA 17 | lhPa2InB4pvBICNbB0lMeI/ORY0AyxmdZ+VhMSnLHe0mRyonSGrBLycSsotqBxQU 18 | qfNhum9m5fE1Ah3MN15Hnw== 19 | 20 | -------------------------------------------------------------------------------- /tests/authentication-request-signed.xml: -------------------------------------------------------------------------------- 1 | https://sp.example.com/SAML2 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | CxgHIaLL1Ce4iAMD0ry6i++BFP0= 11 | 12 | 13 | IKi1NOMYzrz6muSboAS+0K3gw8waPT1QqL0BYXTcmOHmbgKFRgp29q1+a6tBmNhN 14 | o0inSa9v8CT5omdwCi+3yN1/gXT1sXBq9BJD1I6bhURprTMdRqhsWXiYC7HOtx+b 15 | ISVq+0OfbEhH0WKnwRo+Wc0JdiTGYty1Vu34FRyg+9rrTCjKk40Brl0FDMExSxmM 16 | PPi3RHkEmL2X9g5M2fLjJg4UOjx+K3RyNOvOObo4GfZJf+9B5M/TxB48mojbwj9R 17 | 8kVKDso0YMrNsZOo9c2ptu1gpHxvNS3Y6c1c43X0883s6cGNJSuICoZk/vHMwtjz 18 | EP9ntIMnZ26905JYwsrbAQ== 19 | 20 | -------------------------------------------------------------------------------- /tests/logout-request-signed.xml: -------------------------------------------------------------------------------- 1 | myhost 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | cdDEc7e5AgOZIOTAVr5uq/KCneY= 11 | 12 | 13 | d2qTJAJk8bLpq7dpRy4Ne8RLQwlb8g1Wy71r4EiyDBOri9Kh6AHv3mDrMLwCiFx7 14 | jcoYZtLWhQRglA5Icl8Z3WnPZDR/7EyRyVLyEnMFwLF2ZuXCBWlAhIJsJX+BBOd8 15 | 5IKXOMw3QLFshGWwj/npCYpHckDTU9uvOi0Q3Flg4lgXNUFZ94iUD0W0uqxFZmm5 16 | oMJQALvIoi0fsFrXxQ2RTFUFP6TdW4h0KkVHZWSjzN14KGJxpCoh0zxngQ0O7EfV 17 | YIHgBCOPv+Z5IFTOUsdkR4sruOT/fw0FX4cAlFtjt7FSw8e0t/TuzucOU/lTIeqF 18 | yk7Yxka80ncjHmeJlPq7jg== 19 | myemail@mydomain.com_0628125f-7f95-42cc-ad8e-fde86ae90bbe 20 | -------------------------------------------------------------------------------- /saml/schema/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from dateutil.parser import parse as parse_datetime 3 | 4 | 5 | class Base(object): 6 | 7 | def prepare(self, value): 8 | return str(value) if value is not None else None 9 | 10 | def clean(self, text): 11 | if text is not None: 12 | return text 13 | 14 | 15 | class String(Base): 16 | """String values [saml-core § 1.3.1]. 17 | """ 18 | 19 | 20 | class Integer(Base): 21 | """Integral values. 22 | """ 23 | 24 | 25 | class Boolean(Base): 26 | """Boolean values. 27 | """ 28 | 29 | def prepare(self, value): 30 | if value is None: 31 | return None 32 | 33 | return 'true' if value else 'false' 34 | 35 | def clean(self, text): 36 | if not text: 37 | return None 38 | 39 | return text == 'true' 40 | 41 | 42 | class DateTime(Base): 43 | """ 44 | An ISO 8601 formatted, UTC (eg. 2008-01-23T04:56:22Z) 45 | date-time [saml-core § 1.3.3]. 46 | """ 47 | 48 | @staticmethod 49 | def to_iso8601(when): 50 | text = when.strftime("%Y-%m-%dT%H:%M:%SZ") 51 | return text 52 | 53 | @staticmethod 54 | def from_iso8601(when): 55 | return parse_datetime(when) 56 | 57 | def prepare(self, value): 58 | return DateTime.to_iso8601(value) if value is not None else None 59 | 60 | def clean(self, text): 61 | if text is not None: 62 | return DateTime.from_iso8601(text) 63 | -------------------------------------------------------------------------------- /tests/rsakey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAl7j+tD+DNXgWiQTsK2GMv8RfAIFKRebZzeniPJc7Ra2q5o0L 3 | d3EHAU98+X3iGardkVn08c89unhGlhGctltGOXNVI6r3ngBc5elJ7DucP4SZOpCt 4 | 335khsYmcs4xCHl+ExW45b/WVgKNYCFMJxhk+/tVcPYzvS9VcNVefpmupOCqRUcT 5 | qDDVoIqdzCDs5I5RyVTFfz5mLXS/o3r48+yUVzm0rAB1YmFUtNDgUob4XnfsUEOc 6 | 0rqnjGJavLL+88xifiNga8dRSTd4fiUVMKv6tK4ljyL8o0h/8gqKbuD+jfAB7cYz 7 | zGuh/aaA7waMr/ZAOo5CFCBhEh/j/AWxBdVlwwIDAQABAoIBAQCAvt6DnZF9gdW9 8 | l4vAlBqXb88d4phgELCp5tmviLUnP2NSGEWuqR7Eoeru2z9NgIxblvYfazh6Ty22 9 | kmNk6rcAcTnB9oYAcVZjUj8EUuEXlTFhXPvuNpafNu3RZd59znqJP1mSu+LpQWku 10 | NZMlabHnkTLDlGf7FXtvL9/rlgV4qk3QcDVF793JFszWrtK3mnld3KHQ6cuo9iSm 11 | 0rQKtkDjeHsRell8qTQvfBsgG1q2bv8QWT45/eQrra9mMbGTr3DbnXvoeJmTj1VN 12 | XJV7tBNllxxPahlYMByJaf/Tuva5j6HWUEIfYky5ihr2z1P/fNQ2OSCM6SQHpkiG 13 | EXQDueXBAoGBAMfW7KcmToEQEcTiqfey6C1LOLoemcX0/ROUktPq/5JQJRRrT4t7 14 | XevLX0ed8sLyR5T29XQtdnuV0DJfvcJD+6ZwfOcQ+f6ZzCaNXJP97JtEt5kSWY01 15 | Ei+nphZ0RFvPb04V3qDU9dElU26GR36CRBYJyM2WQPx4v+/YyDSZH9kLAoGBAMJc 16 | ZBU8pRbIia/FFOHUlS3v5P18nVmXyOd0fvRq0ZelaQCebTZ4K9wjnCfw//yzkb2Z 17 | 0vZFNB+xVBKB0Pt6nVvnSNzxdQ8EAXVFwHtXa25FUyP2RERQgTvmajqmgWjZsDYp 18 | 6GHcK3ZhmdmscQHF/Q2Uo4scvBcheahm9IXiNskpAoGAXelEgTBhSAmTMCEMmti6 19 | fz6QQ/bJcNu2apMxhOE0hT+gjT34vaWV9481EWTKho5w0TJVGumaem1mz6VqeXaV 20 | Nhw6tiOmN91ysNNRpEJ6BGWAmjCjYNaF21s/k+HDlhmfRuTEIHSzqDuQP6pewrbY 21 | 5Dpo4SQxGfRsznvjacRj0Q0CgYBN247oBvQnDUxCkhNMZ8kersOvW5T4x9neBge5 22 | R3UQZ12Jtu0O7dK8C7PJODyDcTeHmTAuIQjBTVrdUw1xP+v7XcoNX9hBnJws6zUw 23 | 85MAiFrGxCcSqqEqaqHRPtQGOXXiLKV/ViA++tgTn4VhbXtyTkG5P1iFd45xjFSV 24 | sUm7CQKBgDn92tHxzePly1L1mK584TkVryx4cP9RFHpebnmNduGwwjnRuYipoj8y 25 | pPPAkVbbaA3f9OB2go48rN0Ft9nHdlqgh9BpIKCVtkIb1XN0K3Oa/8BW8W/GAiNG 26 | HJcsrOtIrGVRdlyJG6bDaN8T49DnhOcsqMbf+IkIvfh50VeE9L/e 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/artifact-response-signed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | xnNFU/3XnZYzBCpat60kGSKZRp0= 11 | 12 | 13 | iu3hlDNpHFUJplrfBzUIh6tzRaW4v2iDdB4x05K1iGTW2Ik0WA9ShfOGVSzGVPVb 14 | +jej8Nxw/awnszPxTYMbQbjftDp4s5Wdb4vPSGH9C7IByg7oix5MRsri7Hu4rzs9 15 | D7lucJH+4czID2m1c7c4EBq32qTTAqdXsl0NOWJUxp7TofBwwGSZ5sAYQMiXQObB 16 | Mm97gvOwKQ9tgHRMwLOIZNjECteKeGVlUg6y0wQlfykC6XyaM5rsZQo/N7KytN5v 17 | XA6ybMeIJxzWZ13BAAJ4kV9BgLTvKrI3nXtYSSVqf/Ft3ciZSWFf2sflQ77R0iFv 18 | lUEyNfTEi6j2HtaU12pEuQ== 19 | https://sp.example.com/SAML2 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup, find_packages 4 | from imp import load_source 5 | 6 | setup( 7 | name='saml', 8 | version=load_source('', 'saml/_version.py').__version__, 9 | description='A python interface to produce and consume Security ' 10 | 'Assertion Markup Language (SAML) v2.0 messages.', 11 | classifiers=[ 12 | 'Development Status :: 4 - Beta', 13 | 'Intended Audience :: Developers', 14 | 'Intended Audience :: System Administrators', 15 | 'License :: OSI Approved :: MIT License', 16 | 'Operating System :: OS Independent', 17 | 'Programming Language :: Python', 18 | 'Programming Language :: Python :: 2.7', 19 | 'Programming Language :: Python :: 3.3', 20 | 'Programming Language :: Python :: 3.4' 21 | ], 22 | author='Concordus Applications', 23 | author_email='leckey.ryan@gmail.com', 24 | url='http://github.com/mehcode/python-saml', 25 | packages=find_packages('.'), 26 | install_requires=( 27 | # Extensions to the standard Python datetime module. 28 | # Provides ability to easily parse ISO 8601 formatted dates. 29 | 'python-dateutil', 30 | 31 | # lxml is the most feature-rich and easy-to-use library for 32 | # processing XML and HTML in the Python language. 33 | 'lxml', 34 | 35 | # Python bindings for the XML Security Library. 36 | 'xmlsec' 37 | ), 38 | extras_require={ 39 | 'test': ( 40 | # Test runner. 41 | 'pytest', 42 | 43 | # Ensure PEP8 conformance. 44 | 'pytest-pep8', 45 | 46 | # Ensure PyFlakes conformance. 47 | 'pytest-flakes', 48 | 49 | # Ensure test coverage. 50 | 'pytest-cov', 51 | ) 52 | } 53 | ) 54 | -------------------------------------------------------------------------------- /tests/assertion-signed.xml: -------------------------------------------------------------------------------- 1 | https://idp.example.org/SAML2 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 0KtoALnAv524NZukvr5j3XM7y/k= 11 | 12 | 13 | eEVBMicN0s4pck1nv1/YJwT0yQ2GhLDt3nQktBL2vgMgeGp9OMB8odRIerNnWMKA 14 | IeDh7j7Blp7h8NIJO62VbbNHEyL+DmIuVgbigVDdcwnXtN/rci+1inveCztDV09J 15 | /zD6jOEFPS+bxysNT1T5eK9/F38FfOjEjtdE7ZYLoh+JnIXuFWc5bOgp9mbsRDu/ 16 | X72LpTgwascA68yinyvNU5i+vLckskzujOfAQo2H5mC952qk9+d0z9IIjtz2d48G 17 | aUXdiUQPE8lNDlM+IqvyOt8wcGjvMi38bFEXJLfGBfk7fUdee6zcEAAdPW0Rav/m 18 | YFY2LQrnJrJXNPjBI3h2/g== 19 | 3f7b3dcf-1674-4ecd-92c8-1544f346baf8https://sp.example.com/SAML2urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport 20 | -------------------------------------------------------------------------------- /tests/response-simple.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | https://idp.example.org/SAML2 11 | 12 | 13 | 14 | 19 | https://idp.example.org/SAML2 20 | 21 | 3f7b3dcf-1674-4ecd-92c8-1544f346baf8 22 | 24 | 28 | 29 | 30 | 33 | 34 | https://sp.example.com/SAML2 35 | 36 | 37 | 40 | 41 | urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /tests/response-signed.xml: -------------------------------------------------------------------------------- 1 | https://idp.example.org/SAML2 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | qv9GT+D06Nzr8uymGd60HG1ObUM= 11 | 12 | 13 | fNf3jKJwYHgYTsqZpBBGlEq9Hny1uSQyLsJEWp1cGmfidKnf6jJn9PwB4F6F5F/9 14 | UZ6DjmGRAYivVXU1JmUInsHpqyDllOYeKTfksf0RbQ6RNv6AqDDt7ZGOKeKqP3A+ 15 | k0onIqhP8BBcFCRE8/QZPv9RxMqtEtWcQX603khHobGg/JJVniVJBWluwBs6xNOP 16 | xr7ZaP1idmLq+1yiGPhdIcjDyhGujP4Nmmw9jV8iml1YlXT/t+VNRF0y9J+rT8EK 17 | BiOORNDmTLmyUCcf9HnIT11UIPVH/6SDJ4jYvCyzveS9Dp7B9e1R2Dr97kppx3hn 18 | Ag422cWv09Vl9q+YlmoptA== 19 | https://idp.example.org/SAML23f7b3dcf-1674-4ecd-92c8-1544f346baf8https://sp.example.com/SAML2urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport 20 | -------------------------------------------------------------------------------- /saml/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import base64 3 | import zlib 4 | import six 5 | from six.moves.urllib.parse import urlencode, parse_qs, unquote_plus 6 | from lxml import etree 7 | from saml import schema 8 | from saml.schema.base import _element_registry 9 | 10 | 11 | def send(uri, message, relay_state=None, protocol='redirect'): 12 | 13 | # Determine the name of parameter. 14 | element = _element_registry.get(message.tag) 15 | if not element: 16 | raise ValueError('unknown element', message) 17 | 18 | if protocol == 'redirect': 19 | # For sending a message through redirection; we need 20 | # to encode the message (depending on what it is) in the URI 21 | # as SAMLRequest or SAMLResponse. 22 | name = 'SAMLRequest' 23 | if isinstance(element, schema.StatusResponse): 24 | name = 'SAMLResponse' 25 | 26 | # Serialize and encode the message. 27 | text = base64.b64encode(zlib.compress(etree.tostring(message))[2:-4]) 28 | 29 | # Build the parameters. 30 | parameters = {name: text} 31 | if relay_state: 32 | parameters['RelayState'] = relay_state 33 | 34 | # Append the parameters on the uri and return. 35 | uri = '%s?%s' % (uri, urlencode(parameters)) 36 | return uri, None 37 | 38 | raise ValueError('unknown protocol', protocol) 39 | 40 | 41 | def _text(str_or_bytes): 42 | if isinstance(str_or_bytes, six.text_type): 43 | return str_or_bytes 44 | 45 | return str_or_bytes.decode() 46 | 47 | 48 | def receive(method, query_string, body): 49 | # Determine the protocol used and pare the appropriate data. 50 | method = method.upper() 51 | if method == 'GET': 52 | data = parse_qs(_text(query_string)) 53 | binding = 'artifact' if 'SAMLArtifact' in data else 'redirect' 54 | 55 | elif method == 'POST': 56 | data = parse_qs(_text(body)) 57 | binding = 'post' 58 | 59 | else: 60 | # Unknown method used. 61 | return None 62 | 63 | if binding in ('redirect', 'post'): 64 | # Pull the text out of the query. 65 | encoded = data.get('SAMLResponse', data.get('SAMLRequest')) 66 | if not encoded: 67 | # No SAML message found. 68 | return None 69 | 70 | # Decode the text. 71 | text = base64.b64decode(encoded[0]) 72 | if binding == "redirect": 73 | text = zlib.decompress(text, -15) 74 | 75 | # Parse the text into xml. 76 | message = etree.XML(text) 77 | 78 | # Get the relay state if present. 79 | relay_state = data.get('RelayState') 80 | if relay_state: 81 | relay_state = unquote_plus(relay_state[0]) 82 | 83 | # Return the message and the relay state. 84 | return message, relay_state 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-saml 2 | [![Build Status](https://travis-ci.org/mehcode/python-saml.png?branch=master)](https://travis-ci.org/mehcode/python-saml) 3 | [![Coverage Status](https://coveralls.io/repos/mehcode/python-saml/badge.png?branch=master)](https://coveralls.io/r/mehcode/python-saml?branch=master) 4 | [![PyPi Version](https://pypip.in/v/saml/badge.png)](https://pypi.python.org/pypi/saml) 5 | ![PyPi Downloads](https://pypip.in/d/saml/badge.png) 6 | > A python interface to produce and consume Security Asserion Markup Language v2.0 (SAML2) messages. 7 | 8 | ## Features 9 | 10 | ##### Python 2.7.x, 3.3.x, 3.4.x support 11 | 12 | python-saml supports both python 2.7.x+ and 3.3.x+. 13 | 14 | ##### SAML conformance 15 | 16 | python-saml conforms to the latest [SAML][] (v2.0) standards. 17 | 18 | [SAML]: https://www.oasis-open.org/standards#samlv2.0 19 | 20 | ##### Environment agnostic 21 | 22 | python-saml may be used to produce and consume SAML messages regardless of the environment (terminal, WSGI, django) used to call it. 23 | 24 | ## Usage 25 | 26 | ### 27 | 28 | Check the [test suite](https://github.com/mehcode/python-saml/blob/master/tests/saml/test_schema.py#L33) for additional examples on using the library. 29 | 30 | ## Install 31 | 32 | ### Pre-Install 33 | 34 | #### Linux 35 | 36 | ```sh 37 | apt-get install libxml2-dev libxmlsec1-dev 38 | ``` 39 | 40 | #### Mac 41 | 42 | ```sh 43 | brew install libxml2 libxmlsec1 44 | ``` 45 | 46 | 47 | ### Automated 48 | 49 | 1. **saml** can be installed through `easy_install` or `pip`. 50 | 51 | ```sh 52 | pip install saml 53 | ``` 54 | 55 | ### Manual 56 | 57 | 1. Clone the **saml** repository to your local computer. 58 | 59 | ```sh 60 | git clone git://github.com/mehcode/python-saml.git 61 | ``` 62 | 63 | 2. Change into the **saml** root directory. 64 | 65 | ```sh 66 | cd /path/to/saml 67 | ``` 68 | 69 | 3. Install the project and all its dependencies using `pip`. 70 | 71 | ```sh 72 | pip install . 73 | ``` 74 | 75 | ## Contributing 76 | 77 | ### Setting up your environment 78 | 79 | 1. Follow steps 1 and 2 of the [manual installation instructions][]. 80 | 81 | [manual installation instructions]: #manual 82 | 83 | 2. Initialize a virtual environment to develop in. 84 | This is done so as to ensure every contributor is working with 85 | close-to-identicial versions of packages. 86 | 87 | ```sh 88 | mkvirtualenv saml 89 | ``` 90 | 91 | The `mkvirtualenv` command is available from `virtualenvwrapper` which 92 | can be installed by following: http://virtualenvwrapper.readthedocs.org/en/latest/install.html#basic-installation 93 | 94 | 3. Install **saml** in development mode with testing enabled. 95 | This will download all dependencies required for running the unit tests. 96 | 97 | ```sh 98 | pip install -e ".[test]" 99 | ``` 100 | 101 | ### Running the test suite 102 | 103 | 1. [Set up your environment](#setting-up-your-environment). 104 | 105 | 2. Run the unit tests. 106 | 107 | ```sh 108 | py.test 109 | ``` 110 | 111 | ## License 112 | Unless otherwise noted, all files contained within this project are liensed under the MIT opensource license. See the included file LICENSE or visit [opensource.org][] for more information. 113 | 114 | [opensource.org]: http://opensource.org/licenses/MIT 115 | -------------------------------------------------------------------------------- /tests/rsacert.pem: -------------------------------------------------------------------------------- 1 | Certificate: 2 | Data: 3 | Version: 3 (0x2) 4 | Serial Number: 5 (0x5) 5 | Signature Algorithm: md5WithRSAEncryption 6 | Issuer: C=US, ST=California, L=Sunnyvale, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Root Certificate, CN=Aleksey Sanin/emailAddress=xmlsec@aleksey.com 7 | Validity 8 | Not Before: Mar 31 04:02:22 2003 GMT 9 | Not After : Mar 28 04:02:22 2013 GMT 10 | Subject: C=US, ST=California, O=XML Security Library (http://www.aleksey.com/xmlsec), OU=Examples RSA Certificate, CN=Aleksey Sanin/emailAddress=xmlsec@aleksey.com 11 | Subject Public Key Info: 12 | Public Key Algorithm: rsaEncryption 13 | RSA Public Key: (2048 bit) 14 | Modulus (2048 bit): 15 | 00:97:b8:fe:b4:3f:83:35:78:16:89:04:ec:2b:61: 16 | 8c:bf:c4:5f:00:81:4a:45:e6:d9:cd:e9:e2:3c:97: 17 | 3b:45:ad:aa:e6:8d:0b:77:71:07:01:4f:7c:f9:7d: 18 | e2:19:aa:dd:91:59:f4:f1:cf:3d:ba:78:46:96:11: 19 | 9c:b6:5b:46:39:73:55:23:aa:f7:9e:00:5c:e5:e9: 20 | 49:ec:3b:9c:3f:84:99:3a:90:ad:df:7e:64:86:c6: 21 | 26:72:ce:31:08:79:7e:13:15:b8:e5:bf:d6:56:02: 22 | 8d:60:21:4c:27:18:64:fb:fb:55:70:f6:33:bd:2f: 23 | 55:70:d5:5e:7e:99:ae:a4:e0:aa:45:47:13:a8:30: 24 | d5:a0:8a:9d:cc:20:ec:e4:8e:51:c9:54:c5:7f:3e: 25 | 66:2d:74:bf:a3:7a:f8:f3:ec:94:57:39:b4:ac:00: 26 | 75:62:61:54:b4:d0:e0:52:86:f8:5e:77:ec:50:43: 27 | 9c:d2:ba:a7:8c:62:5a:bc:b2:fe:f3:cc:62:7e:23: 28 | 60:6b:c7:51:49:37:78:7e:25:15:30:ab:fa:b4:ae: 29 | 25:8f:22:fc:a3:48:7f:f2:0a:8a:6e:e0:fe:8d:f0: 30 | 01:ed:c6:33:cc:6b:a1:fd:a6:80:ef:06:8c:af:f6: 31 | 40:3a:8e:42:14:20:61:12:1f:e3:fc:05:b1:05:d5: 32 | 65:c3 33 | Exponent: 65537 (0x10001) 34 | X509v3 extensions: 35 | X509v3 Basic Constraints: 36 | CA:FALSE 37 | Netscape Comment: 38 | OpenSSL Generated Certificate 39 | X509v3 Subject Key Identifier: 40 | 24:84:2C:F2:D4:59:20:62:8B:2E:5C:86:90:A3:AA:30:BA:27:1A:9C 41 | X509v3 Authority Key Identifier: 42 | keyid:B4:B9:EF:9A:E6:97:0E:68:65:1E:98:CE:FA:55:0D:89:06:DB:4C:7C 43 | DirName:/C=US/ST=California/L=Sunnyvale/O=XML Security Library (http://www.aleksey.com/xmlsec)/OU=Root Certificate/CN=Aleksey Sanin/emailAddress=xmlsec@aleksey.com 44 | serial:00 45 | 46 | Signature Algorithm: md5WithRSAEncryption 47 | b5:3f:9b:32:31:4a:ff:2f:84:3b:a8:9b:11:5c:a6:5c:f0:76: 48 | 52:d9:6e:f4:90:ad:fa:0d:90:c1:98:d5:4a:12:dd:82:6b:37: 49 | e8:d9:2d:62:92:c9:61:37:98:86:8f:a4:49:6a:5e:25:d0:18: 50 | 69:30:0f:98:8f:43:58:89:31:b2:3b:05:e2:ef:c7:a6:71:5f: 51 | f7:fe:73:c5:a7:b2:cd:2e:73:53:71:7d:a8:4c:68:1a:32:1b: 52 | 5e:48:2f:8f:9b:7a:a3:b5:f3:67:e8:b1:a2:89:4e:b2:4d:1b: 53 | 79:9c:ff:f0:0d:19:4f:4e:b1:03:3d:99:f0:44:b7:8a:0b:34: 54 | 9d:83 55 | -----BEGIN CERTIFICATE----- 56 | MIIE3zCCBEigAwIBAgIBBTANBgkqhkiG9w0BAQQFADCByzELMAkGA1UEBhMCVVMx 57 | EzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVN1bm55dmFsZTE9MDsGA1UE 58 | ChM0WE1MIFNlY3VyaXR5IExpYnJhcnkgKGh0dHA6Ly93d3cuYWxla3NleS5jb20v 59 | eG1sc2VjKTEZMBcGA1UECxMQUm9vdCBDZXJ0aWZpY2F0ZTEWMBQGA1UEAxMNQWxl 60 | a3NleSBTYW5pbjEhMB8GCSqGSIb3DQEJARYSeG1sc2VjQGFsZWtzZXkuY29tMB4X 61 | DTAzMDMzMTA0MDIyMloXDTEzMDMyODA0MDIyMlowgb8xCzAJBgNVBAYTAlVTMRMw 62 | EQYDVQQIEwpDYWxpZm9ybmlhMT0wOwYDVQQKEzRYTUwgU2VjdXJpdHkgTGlicmFy 63 | eSAoaHR0cDovL3d3dy5hbGVrc2V5LmNvbS94bWxzZWMpMSEwHwYDVQQLExhFeGFt 64 | cGxlcyBSU0EgQ2VydGlmaWNhdGUxFjAUBgNVBAMTDUFsZWtzZXkgU2FuaW4xITAf 65 | BgkqhkiG9w0BCQEWEnhtbHNlY0BhbGVrc2V5LmNvbTCCASIwDQYJKoZIhvcNAQEB 66 | BQADggEPADCCAQoCggEBAJe4/rQ/gzV4FokE7CthjL/EXwCBSkXm2c3p4jyXO0Wt 67 | quaNC3dxBwFPfPl94hmq3ZFZ9PHPPbp4RpYRnLZbRjlzVSOq954AXOXpSew7nD+E 68 | mTqQrd9+ZIbGJnLOMQh5fhMVuOW/1lYCjWAhTCcYZPv7VXD2M70vVXDVXn6ZrqTg 69 | qkVHE6gw1aCKncwg7OSOUclUxX8+Zi10v6N6+PPslFc5tKwAdWJhVLTQ4FKG+F53 70 | 7FBDnNK6p4xiWryy/vPMYn4jYGvHUUk3eH4lFTCr+rSuJY8i/KNIf/IKim7g/o3w 71 | Ae3GM8xrof2mgO8GjK/2QDqOQhQgYRIf4/wFsQXVZcMCAwEAAaOCAVcwggFTMAkG 72 | A1UdEwQCMAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRp 73 | ZmljYXRlMB0GA1UdDgQWBBQkhCzy1FkgYosuXIaQo6owuicanDCB+AYDVR0jBIHw 74 | MIHtgBS0ue+a5pcOaGUemM76VQ2JBttMfKGB0aSBzjCByzELMAkGA1UEBhMCVVMx 75 | EzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCVN1bm55dmFsZTE9MDsGA1UE 76 | ChM0WE1MIFNlY3VyaXR5IExpYnJhcnkgKGh0dHA6Ly93d3cuYWxla3NleS5jb20v 77 | eG1sc2VjKTEZMBcGA1UECxMQUm9vdCBDZXJ0aWZpY2F0ZTEWMBQGA1UEAxMNQWxl 78 | a3NleSBTYW5pbjEhMB8GCSqGSIb3DQEJARYSeG1sc2VjQGFsZWtzZXkuY29tggEA 79 | MA0GCSqGSIb3DQEBBAUAA4GBALU/mzIxSv8vhDuomxFcplzwdlLZbvSQrfoNkMGY 80 | 1UoS3YJrN+jZLWKSyWE3mIaPpElqXiXQGGkwD5iPQ1iJMbI7BeLvx6ZxX/f+c8Wn 81 | ss0uc1NxfahMaBoyG15IL4+beqO182fosaKJTrJNG3mc//ANGU9OsQM9mfBEt4oL 82 | NJ2D 83 | -----END CERTIFICATE----- 84 | -------------------------------------------------------------------------------- /saml/signature.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Sign and verify signatures using the `python-xmlsec` library. 5 | 6 | .. autofunction:: sign 7 | .. autofunction:: verify 8 | """ 9 | 10 | 11 | def sign(xml, stream, password=None): 12 | """ 13 | Sign an XML document with the given private key file. This will add a 14 | element to the document. 15 | 16 | :param lxml.etree._Element xml: The document to sign 17 | :param file stream: The private key to sign the document with 18 | :param str password: The password used to access the private key 19 | 20 | :rtype: None 21 | 22 | Example usage: 23 | :: 24 | from saml import schema 25 | from lxml import etree 26 | 27 | document = schema.AuthenticationRequest() 28 | xml_document = document.serialize() 29 | with open('my_key_file.pem', 'r+') as stream: 30 | sign(xml_document, stream) 31 | 32 | print etree.tostring(xml_document) 33 | 34 | Produces the following XML document: 35 | 36 | .. code-block:: xml 37 | 38 | 43 | 44 | 45 | 47 | 50 | 51 | 52 | 55 | 56 | 59 | 60 | 94O1FOjRE4JQYVDqStkYzne9StQ= 61 | 62 | 63 | 64 | 65 | aFYRRjtB3bDyLLJzLZmsn0K4SXmOpFYJ+8R8D31VojgiF37FOElbE56UFbm8BAjn 66 | l2AixrUGXP4djxoxxnfBD/reYw5yVuIVXlMxKec784nF2V4GyrfwJOKaNmlVPkq5 67 | c8SI+EkKJ02mwiail0Zvjb9FzwvlYD+osMSXvJXVqnGHQDVFlhwbBRRVB6t44/M3 68 | TzC4mLSVhuvcpsm4GTQSpGkHP7HvweKN/OTc0aTy8Kh/YUrImwnUCii+J0EW4nGg 69 | 71eZyq/IiSPnTD09WDHsWe3g29kpicZXqrQCWeLE2zfVKtyxxs7PyEmodH19jXyz 70 | wh9hQ8t6PFO47Ros5aV0bw== 71 | 72 | 73 | 74 | """ 75 | 76 | # Import xmlsec here to delay initializing the C library in 77 | # case we don't need it. 78 | import xmlsec 79 | 80 | # Resolve the SAML/2.0 element in question. 81 | from saml.schema.base import _element_registry 82 | element = _element_registry.get(xml.tag) 83 | 84 | # Create a signature template for RSA-SHA1 enveloped signature. 85 | signature_node = xmlsec.template.create( 86 | xml, 87 | xmlsec.Transform.EXCL_C14N, 88 | xmlsec.Transform.RSA_SHA1) 89 | 90 | # Add the node to the document. 91 | xml.insert(element.meta.signature_index, signature_node) 92 | 93 | # Add the node to the signature template. 94 | ref = xmlsec.template.add_reference( 95 | signature_node, xmlsec.Transform.SHA1) 96 | 97 | # Add the enveloped transform descriptor. 98 | xmlsec.template.add_transform(ref, xmlsec.Transform.ENVELOPED) 99 | 100 | # Create a digital signature context (no key manager is needed). 101 | ctx = xmlsec.SignatureContext() 102 | 103 | # Load private key. 104 | key = xmlsec.Key.from_memory(stream, xmlsec.KeyFormat.PEM, password) 105 | 106 | # Set the key on the context. 107 | ctx.key = key 108 | 109 | # Sign the template. 110 | ctx.sign(signature_node) 111 | 112 | 113 | def verify(xml, stream): 114 | """ 115 | Verify the signaure of an XML document with the given certificate. 116 | Returns `True` if the document is signed with a valid signature. 117 | Returns `False` if the document is not signed or if the signature is 118 | invalid. 119 | 120 | :param lxml.etree._Element xml: The document to sign 121 | :param file stream: The private key to sign the document with 122 | 123 | :rtype: Boolean 124 | """ 125 | # Import xmlsec here to delay initializing the C library in 126 | # case we don't need it. 127 | import xmlsec 128 | 129 | # Find the node. 130 | signature_node = xmlsec.tree.find_node(xml, xmlsec.Node.SIGNATURE) 131 | if signature_node is None: 132 | # No `signature` node found; we cannot verify 133 | return False 134 | 135 | # Create a digital signature context (no key manager is needed). 136 | ctx = xmlsec.SignatureContext() 137 | 138 | # Register and 139 | ctx.register_id(xml) 140 | for assertion in xml.xpath("//*[local-name()='Assertion']"): 141 | ctx.register_id(assertion) 142 | 143 | # Load the public key. 144 | key = None 145 | for fmt in [ 146 | xmlsec.KeyFormat.PEM, 147 | xmlsec.KeyFormat.CERT_PEM]: 148 | stream.seek(0) 149 | try: 150 | key = xmlsec.Key.from_memory(stream, fmt) 151 | break 152 | except ValueError: 153 | # xmlsec now throws when it can't load the key 154 | pass 155 | 156 | # Set the key on the context. 157 | ctx.key = key 158 | 159 | # Verify the signature. 160 | try: 161 | ctx.verify(signature_node) 162 | 163 | return True 164 | 165 | except Exception: 166 | return False 167 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-saml.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-saml.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/python-saml" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python-saml" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import saml 2 | import xmlsec 3 | from saml import schema 4 | from saml.schema import utils 5 | from datetime import datetime 6 | from lxml import etree 7 | from os import path 8 | from pytest import mark 9 | 10 | BASE_DIR = path.abspath(path.dirname(__file__)) 11 | 12 | 13 | def strip(text): 14 | if not text: 15 | return None 16 | 17 | text = text.replace('\n', '') 18 | text = text.strip() 19 | 20 | return text if text else None 21 | 22 | 23 | def assert_node(expected, result): 24 | assert expected.tag == result.tag 25 | assert expected.attrib == result.attrib 26 | assert strip(expected.text) == strip(result.text) 27 | assert strip(expected.tail) == strip(result.tail) 28 | assert len(expected) == len(result) 29 | 30 | for expected, result in zip(expected, result): 31 | assert_node(expected, result) 32 | 33 | 34 | def build_assertion_simple(): 35 | # Create the assertion object. 36 | target = schema.Assertion() 37 | target.id = 'b07b804c-7c29-ea16-7300-4f3d6f7928ac' 38 | target.issue_instant = datetime(2004, 12, 5, 9, 22, 5) 39 | target.issuer = 'https://idp.example.org/SAML2' 40 | 41 | # Create a subject. 42 | target.subject = subject = schema.Subject() 43 | subject.principal = '3f7b3dcf-1674-4ecd-92c8-1544f346baf8' 44 | subject.principal.format = schema.NameID.Format.TRANSIENT 45 | subject.confirmation = confirmation = schema.SubjectConfirmation() 46 | confirmation.data = data = schema.SubjectConfirmationData() 47 | data.in_response_to = 'aaf23196-1773-2113-474a-fe114412ab72' 48 | data.not_on_or_after = datetime(2004, 12, 5, 9, 27, 5) 49 | data.recipient = 'https://sp.example.com/SAML2/SSO/POST' 50 | del data.recipient 51 | data.recipient = 'https://sp.example.com/SAML2/SSO/POST' 52 | 53 | assert data.recipient == 'https://sp.example.com/SAML2/SSO/POST' 54 | 55 | # Create an authentication statement. 56 | statement = schema.AuthenticationStatement() 57 | target.statements.append(statement) 58 | statement.authn_instant = datetime(2004, 12, 5, 9, 22, 0) 59 | statement.session_index = 'b07b804c-7c29-ea16-7300-4f3d6f7928ac' 60 | ref = schema.AuthenticationContextReference.PASSWORD_PROTECTED_TRANSPORT 61 | statement.context.reference = ref 62 | 63 | # Create a authentication condition. 64 | target.conditions = conditions = schema.Conditions() 65 | conditions.not_before = datetime(2004, 12, 5, 9, 17, 5) 66 | conditions.not_on_or_after = datetime(2004, 12, 5, 9, 27, 5) 67 | condition = schema.AudienceRestriction() 68 | condition.audiences = 'https://sp.example.com/SAML2' 69 | conditions.condition = condition 70 | 71 | # Return the built object. 72 | return target 73 | 74 | 75 | def build_authentication_request_simple(): 76 | # Create the authentication request. 77 | target = schema.AuthenticationRequest() 78 | target.id = 'aaf23196-1773-2113-474a-fe114412ab72' 79 | target.issue_instant = datetime(2004, 12, 5, 9, 21, 59) 80 | target.assertion_consumer_service_index = 0 81 | target.attribute_consuming_service_index = 0 82 | target.issuer = 'https://sp.example.com/SAML2' 83 | 84 | # Add a name id policy to the request. 85 | target.policy = policy = schema.NameIDPolicy() 86 | policy.allow_create = True 87 | policy.format = schema.NameID.Format.TRANSIENT 88 | 89 | # Return the built object. 90 | return target 91 | 92 | 93 | def build_artifact_resolve_simple(): 94 | # Create the artifact resolution request. 95 | target = schema.ArtifactResolve() 96 | target.id = '_cce4ee769ed970b501d680f697989d14' 97 | target.issue_instant = datetime(2004, 12, 5, 9, 21, 58) 98 | target.issuer = 'https://idp.example.org/SAML2' 99 | target.artifact = ''' 100 | AAQAAMh48/1oXIM+sDo7Dh2qMp1HM4IF5DaRNmDj6RdUmllwn9jJHyEgIi8= 101 | '''.strip() 102 | 103 | # Return the built object. 104 | return target 105 | 106 | 107 | def build_artifact_response_simple(): 108 | # Create the artifact response. 109 | target = schema.ArtifactResponse() 110 | target.id = '_d84a49e5958803dedcff4c984c2b0d95' 111 | target.in_response_to = '_cce4ee769ed970b501d680f697989d14' 112 | target.issue_instant = datetime(2004, 12, 5, 9, 21, 59) 113 | target.status.code.value = schema.StatusCode.SUCCESS 114 | 115 | # Create an authentication request to stuff inside of the artifact 116 | # response. 117 | target.message = message = schema.AuthenticationRequest() 118 | message.id = '_306f8ec5b618f361c70b6ffb1480eade' 119 | message.issue_instant = datetime(2004, 12, 5, 9, 21, 59) 120 | message.destination = 'https://idp.example.org/SAML2/SSO/Artifact' 121 | message.protocol = schema.Protocol.ARTIFACT 122 | message.issuer = 'https://sp.example.com/SAML2' 123 | message.assertion_consumer_service_url = ( 124 | 'https://sp.example.com/SAML2/SSO/Artifact') 125 | 126 | # Add a name id policy to the authentication request. 127 | message.policy = policy = schema.NameIDPolicy() 128 | policy.allow_create = False 129 | policy.format = schema.NameID.Format.EMAIL 130 | 131 | # Return the built object. 132 | return target 133 | 134 | 135 | def build_response_simple(): 136 | # Create the response. 137 | target = schema.Response() 138 | target.id = 'identifier_2' 139 | target.in_response_to = 'identifier_1' 140 | target.issue_instant = datetime(2004, 12, 5, 9, 22, 5) 141 | target.issuer = 'https://idp.example.org/SAML2' 142 | target.destination = 'https://sp.example.com/SAML2/SSO/POST' 143 | target.status.code.value = schema.StatusCode.SUCCESS 144 | 145 | # Create an assertion for the response. 146 | target.assertions = assertion = schema.Assertion() 147 | assertion.id = 'identifier_3' 148 | assertion.issue_instant = datetime(2004, 12, 5, 9, 22, 5) 149 | assertion.issuer = 'https://idp.example.org/SAML2' 150 | 151 | # Create a subject. 152 | assertion.subject = subject = schema.Subject() 153 | subject.principal = '3f7b3dcf-1674-4ecd-92c8-1544f346baf8' 154 | subject.principal.format = schema.NameID.Format.TRANSIENT 155 | subject.confirmation = confirmation = schema.SubjectConfirmation() 156 | confirmation.data = data = schema.SubjectConfirmationData() 157 | data.in_response_to = 'identifier_1' 158 | data.not_on_or_after = datetime(2004, 12, 5, 9, 27, 5) 159 | data.recipient = 'https://sp.example.com/SAML2/SSO/POST' 160 | 161 | # Create an authentication statement. 162 | statement = schema.AuthenticationStatement() 163 | assertion.statements.append(statement) 164 | statement.authn_instant = datetime(2004, 12, 5, 9, 22, 0) 165 | statement.session_index = 'identifier_3' 166 | ref = schema.AuthenticationContextReference.PASSWORD_PROTECTED_TRANSPORT 167 | statement.context.reference = ref 168 | 169 | # Create a authentication condition. 170 | assertion.conditions = conditions = schema.Conditions() 171 | conditions.not_before = datetime(2004, 12, 5, 9, 17, 5) 172 | conditions.not_on_or_after = datetime(2004, 12, 5, 9, 27, 5) 173 | condition = schema.AudienceRestriction() 174 | condition.audiences = 'https://sp.example.com/SAML2' 175 | conditions.condition = condition 176 | 177 | # Return the built object. 178 | return target 179 | 180 | 181 | def build_logout_request_simple(): 182 | # Create the request. 183 | target = schema.LogoutRequest() 184 | target.id = 'identifier_1' 185 | target.issue_instant = datetime(2008, 6, 3, 12, 59, 57) 186 | target.issuer = 'myhost' 187 | target.destination = 'https://idphost/adfs/ls/' 188 | target.principal = 'myemail@mydomain.com' 189 | target.principal.format = schema.NameID.Format.EMAIL 190 | target.principal.name_qualifier = 'https://idphost/adfs/ls/' 191 | target.session_index = '_0628125f-7f95-42cc-ad8e-fde86ae90bbe' 192 | 193 | # Return the built object. 194 | return target 195 | 196 | 197 | def build_logout_response_simple(): 198 | # Create the response. 199 | target = schema.LogoutResponse() 200 | target.id = 'identifier_2' 201 | target.in_response_to = 'identifier_1' 202 | target.issue_instant = datetime(2004, 12, 5, 9, 22, 5) 203 | target.issuer = 'https://idp.example.org/SAML2' 204 | target.destination = 'https://sp.example.com/SAML2/SLO/POST' 205 | target.status.code.value = schema.StatusCode.SUCCESS 206 | 207 | # Return the built object. 208 | return target 209 | 210 | 211 | NAMES = [ 212 | 'assertion', 213 | 'authentication-request', 214 | 'response', 215 | 'logout-request', 216 | 'logout-response', 217 | 'artifact-resolve', 218 | 'artifact-response' 219 | ] 220 | 221 | 222 | @mark.parametrize('name', NAMES) 223 | def test_simple_serialize(name): 224 | # Load the expected result. 225 | filename = path.join(BASE_DIR, '%s-simple.xml' % name) 226 | parser = etree.XMLParser( 227 | ns_clean=True, remove_blank_text=True, remove_comments=True) 228 | expected = etree.parse(filename, parser).getroot() 229 | 230 | # Build the result. 231 | build_fn_name = ('build-%s-simple' % name).replace('-', '_') 232 | target = globals()[build_fn_name]() 233 | 234 | # Serialize the result into an XML object. 235 | result = target.serialize() 236 | 237 | # Resolve and compare the result against the expected. 238 | assert_node(expected, result) 239 | 240 | 241 | @mark.parametrize('name', NAMES) 242 | def test_simple_deserialize(name): 243 | # Load the result. 244 | filename = path.join(BASE_DIR, '%s-simple.xml' % name) 245 | parser = etree.XMLParser( 246 | ns_clean=True, remove_blank_text=True, remove_comments=True) 247 | target = etree.parse(filename, parser).getroot() 248 | 249 | # Build the expected result. 250 | build_fn_name = ('build-%s-simple' % name).replace('-', '_') 251 | expected = globals()[build_fn_name]().serialize() 252 | 253 | # Deserialize and subsequently serialize the target. 254 | cls_name = utils.pascalize(name) 255 | result = getattr(schema, cls_name).deserialize(target).serialize() 256 | 257 | # Compare the nodes. 258 | assert_node(expected, result) 259 | 260 | 261 | @mark.parametrize('name', NAMES) 262 | def test_signed_deserialize(name): 263 | # Load the result. 264 | filename = path.join(BASE_DIR, '%s-signed.xml' % name) 265 | parser = etree.XMLParser( 266 | ns_clean=True, remove_blank_text=True, remove_comments=True) 267 | target = etree.parse(filename, parser).getroot() 268 | 269 | # Build the expected result. 270 | build_fn_name = ('build-%s-simple' % name).replace('-', '_') 271 | expected = globals()[build_fn_name]().serialize() 272 | 273 | # Deserialize and subsequently serialize the target. 274 | cls_name = utils.pascalize(name) 275 | result = getattr(schema, cls_name).deserialize(target).serialize() 276 | 277 | # TODO: Remove the signature node. 278 | 279 | # Compare the nodes. 280 | assert_node(expected, result) 281 | 282 | 283 | @mark.parametrize('name', NAMES) 284 | def test_generic_deserialize(name): 285 | filename = path.join(BASE_DIR, '%s-simple.xml' % name) 286 | parser = etree.XMLParser( 287 | ns_clean=True, remove_blank_text=True, remove_comments=True) 288 | target = etree.parse(filename, parser).getroot() 289 | 290 | build_fn_name = ('build-%s-simple' % name).replace('-', '_') 291 | expected = globals()[build_fn_name]().serialize() 292 | 293 | result = schema.deserialize(target).serialize() 294 | 295 | assert_node(expected, result) 296 | 297 | 298 | def test_generic_deserialize_outside_registry(): 299 | xml = build_authentication_request_simple().serialize() 300 | xml.tag = 'BadTagName' 301 | result = schema.deserialize(xml) 302 | 303 | assert result is None 304 | 305 | 306 | @mark.parametrize('name', NAMES) 307 | def test_sign(name): 308 | # Load the expected result. 309 | filename = path.join(BASE_DIR, '%s-signed.xml' % name) 310 | expected = etree.parse(filename).getroot() 311 | 312 | # Build the result. 313 | build_fn_name = ('build-%s-simple' % name).replace('-', '_') 314 | target = globals()[build_fn_name]() 315 | 316 | # Serialize the result into an XML object. 317 | # Round-trips the XML to remove weird spacing. 318 | result = target.serialize() 319 | 320 | with open(path.join(BASE_DIR, 'rsakey.pem'), 'r') as stream: 321 | # Sign the result. 322 | saml.sign(result, stream) 323 | 324 | # Compare the nodes. 325 | assert_node(expected, result) 326 | 327 | 328 | @mark.parametrize('name', NAMES) 329 | def test_verify(name): 330 | # Load the SAML XML document to verify. 331 | filename = path.join(BASE_DIR, '%s-signed.xml' % name) 332 | expected = etree.parse(filename).getroot() 333 | 334 | # Sign the result. 335 | with open(path.join(BASE_DIR, 'rsapub.pem'), 'r') as stream: 336 | assert saml.verify(expected, stream) 337 | 338 | 339 | @mark.parametrize('name', NAMES) 340 | def test_verify_with_bad_signature_returns_False(name): 341 | filename = path.join(BASE_DIR, '%s-signed.xml' % name) 342 | expected = etree.parse(filename).getroot() 343 | 344 | signature_node = xmlsec.tree.find_node(expected, xmlsec.Node.SIGNATURE) 345 | signature_node.clear() 346 | 347 | with open(path.join(BASE_DIR, 'rsapub.pem'), 'r') as stream: 348 | assert saml.verify(expected, stream) is False 349 | 350 | 351 | @mark.parametrize('name', NAMES) 352 | def test_verify_with_no_signature_returns_False(name): 353 | filename = path.join(BASE_DIR, '%s-simple.xml' % name) 354 | expected = etree.parse(filename).getroot() 355 | 356 | with open(path.join(BASE_DIR, 'rsapub.pem'), 'r') as stream: 357 | assert saml.verify(expected, stream) is False 358 | -------------------------------------------------------------------------------- /saml/schema/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import OrderedDict 3 | from lxml import etree 4 | import six 5 | from .utils import pascalize, classproperty 6 | 7 | 8 | class Options(object): 9 | 10 | def __init__(self, meta, name, data, bases): 11 | """ 12 | Initializes the options object and defaults configuration not 13 | specified. 14 | """ 15 | 16 | # Name of the element in its serialized form. 17 | self.name = meta.get('name') 18 | if self.name is None: 19 | # Generate a name if none is provided. 20 | self.name = pascalize(name) 21 | 22 | # The namespace of the element. 23 | self.namespace = meta.get('namespace') 24 | 25 | # Index into the elements of where we stick the signature block. 26 | self.signature_index = meta.get('signature_index', 1) 27 | 28 | # Element registry in order to lookup for deserialize. 29 | _element_registry = {} 30 | 31 | 32 | class Declarative(type): 33 | 34 | @classmethod 35 | def __prepare__(cls, name, bases): 36 | return OrderedDict() 37 | 38 | @classmethod 39 | def _gather_metadata(cls, metadata, bases): 40 | for base in bases: 41 | if isinstance(base, cls) and hasattr(base, 'Meta'): 42 | # Append metadata. 43 | metadata.append(getattr(base, 'Meta')) 44 | 45 | # Recurse. 46 | cls._gather_metadata(metadata, base.__bases__) 47 | 48 | @classmethod 49 | def _is_derived(cls, name, bases): 50 | for base in bases: 51 | if isinstance(base, cls): 52 | # This is some sort of derived resource; good. 53 | return True 54 | 55 | # This is not derived at all from Resource (eg. is base). 56 | return False 57 | 58 | @classmethod 59 | def _get_attributes_dict(cls, obj): 60 | return {n: getattr(obj, n) for n in dir(obj)} 61 | 62 | def __new__(cls, name, bases, attrs): 63 | # Only continue if we are dervied from declarative. 64 | if not cls._is_derived(name, bases): 65 | return super(Declarative, cls).__new__( 66 | cls, name, bases, attrs) 67 | 68 | # Gather the attributes of all options classes. 69 | # Start with the base configuration. 70 | metadata = {} 71 | 72 | # Expand the options class with the gathered metadata. 73 | base_meta = [] 74 | cls._gather_metadata(base_meta, bases) 75 | 76 | # Apply the configuration from each class in the chain. 77 | for meta in base_meta: 78 | metadata.update(**cls._get_attributes_dict(meta)) 79 | 80 | # Apply the configuration from the current class. 81 | cur_meta = {} 82 | if attrs.get('Meta'): 83 | cur_meta = cls._get_attributes_dict(attrs['Meta']) 84 | metadata.update(**cur_meta) 85 | 86 | # Gather and construct the options object. 87 | meta = attrs['meta'] = Options(metadata, name, cur_meta, base_meta) 88 | 89 | # Collect declared attributes. 90 | attrs['_items'] = OrderedDict() 91 | 92 | # Collect attributes from base classes. 93 | for base in bases: 94 | values = getattr(base, '_items', None) 95 | if values: 96 | attrs['_items'].update(values) 97 | 98 | # Collect attributes from current class. 99 | attrs_l = list(filter(lambda x: issubclass(type(x[1]), Component), 100 | attrs.items())) 101 | attrs_l.sort(key=lambda x: x[1].creation_counter) 102 | for key, attr in attrs_l: 103 | # If name reference is null; default to camel-cased name. 104 | if attr._name is None: 105 | attr._name = pascalize(key) 106 | 107 | # Store attribute in dictionary. 108 | attrs['_items'][attr._name] = attr 109 | 110 | # Continue initialization. 111 | obj = super(Declarative, cls).__new__(cls, name, bases, attrs) 112 | 113 | # Add this element to the element registry. 114 | _element_registry[obj.name] = obj 115 | 116 | # Return the constructed element class. 117 | return obj 118 | 119 | 120 | class Component(object): 121 | 122 | # Tracks each time this field is created; used to keep fields 123 | # in order 124 | creation_counter = 0 125 | 126 | def __init__(self, type_, name=None, required=False, default=None): 127 | # Name of the attribute in its serialized form. 128 | self._name = name 129 | 130 | # Underlying type of the attribute. 131 | self.type = type_ 132 | 133 | # Whether the attribute is required or not. 134 | self.required = required 135 | 136 | # The default value for this attribute. 137 | self.default = default 138 | if not callable(default): 139 | # Normalize self.default to always be a callable. 140 | self.default = lambda: default 141 | 142 | # Adjust the creation counter, and save our local copy. 143 | self.creation_counter = Component.creation_counter 144 | Component.creation_counter += 1 145 | 146 | def __delete__(self, instance): 147 | if instance is not None: 148 | # Being accessed as an instance; use the instance state. 149 | if self._name in instance._state: 150 | del instance._state[self._name] 151 | return 152 | 153 | # Prevent deletion. 154 | raise TypeError("attribute can't be deleted") 155 | 156 | 157 | class Element(Component): 158 | 159 | def __init__(self, type_, **kwargs): 160 | # If an element is a collection it is a list of elements. 161 | self.collection = kwargs.pop('collection', False) 162 | 163 | # Continue the initialization the base element. 164 | super(Element, self).__init__(type_, **kwargs) 165 | 166 | @property 167 | def name(self): 168 | # Return the namespaced name of the element. 169 | return '{%s}%s' % (self.type.meta.namespace[1], self._name) 170 | 171 | @property 172 | def namespace(self): 173 | # Return the namespace of the underlying type. 174 | return self.type.meta.namespace 175 | 176 | def prepare(self, instance): 177 | # Retrieve the value of this attribute from the instance. 178 | value = instance._state.get(self._name) 179 | if value is None and self.default: 180 | # No value; use the default callable. 181 | self.__set__(instance, self.default()) 182 | value = instance._state.get(self._name) 183 | 184 | # Return the value. 185 | return value 186 | 187 | def deserialize(self, xml): 188 | return self.type.deserialize(xml) 189 | 190 | def __get__(self, instance, owner=None): 191 | if instance is not None: 192 | # Being accessed as an instance; use the instance state. 193 | value = instance._state.get(self._name) 194 | if value is None and self.collection: 195 | # No value and we need to be a collection of things. 196 | instance._state[self._name] = value = [] 197 | 198 | if value is None: 199 | # Build a default one of ourself. 200 | instance._state[self._name] = value = self.type() 201 | 202 | # Return the value. 203 | return value 204 | 205 | # Return ourself. 206 | return self 207 | 208 | def __set__(self, instance, value): 209 | if instance is not None: 210 | if isinstance(value, str): 211 | # Value is just text; construct the type. 212 | value = self.type(value) 213 | 214 | # Being accessed as an instance; use the instance state. 215 | if self.collection: 216 | if self._name not in instance._state: 217 | instance._state[self._name] = [] 218 | 219 | instance._state[self._name].append(value) 220 | 221 | else: 222 | instance._state[self._name] = value 223 | 224 | return 225 | 226 | # Prevent assignment. 227 | raise TypeError("attribute can't be assigned") 228 | 229 | 230 | class Attribute(Component): 231 | 232 | def __init__(self, type_, name=None, required=False, default=None): 233 | # Initialize the base element first. 234 | super(Attribute, self).__init__( 235 | type_, name=name, required=required, default=default) 236 | 237 | # Instantiate the type reference with no parameters. 238 | if isinstance(self.type, type): 239 | self.type = self.type() 240 | 241 | @property 242 | def name(self): 243 | return self._name 244 | 245 | def prepare(self, instance): 246 | # Retrieve the value of this attribute from the instance. 247 | value = instance._state.get(self.name) 248 | if value is None and self.default: 249 | # No value; use the default callable. 250 | value = self.default() 251 | 252 | # Run the value through the underyling type's preparation method. 253 | value = self.type.prepare(value) 254 | 255 | # Return the value. 256 | return value 257 | 258 | def clean(self, text): 259 | # Wipe off the passed text and squish it into a python object 260 | # if needed. 261 | return self.type.clean(text) 262 | 263 | def __get__(self, instance, owner=None): 264 | if instance is not None: 265 | # Being accessed as an instance; use the instance state. 266 | return instance._state.get(self._name) 267 | 268 | # Return ourself. 269 | return self 270 | 271 | def __set__(self, instance, value): 272 | if instance is not None: 273 | # Being accessed as an instance; use the instance state. 274 | instance._state[self._name] = value 275 | return 276 | 277 | # Prevent assignment. 278 | raise TypeError("attribute can't be assigned") 279 | 280 | 281 | class Base(six.with_metaclass(Declarative)): 282 | 283 | def __init__(self, text=None, **kwargs): 284 | # Instance state of the attribute. 285 | self._state = {} 286 | 287 | # Text of the element. 288 | self.text = text 289 | 290 | # Update the instance state with kwargs. 291 | self._state.update(kwargs) 292 | 293 | # The signature function tuple. 294 | self._sign_args = None 295 | 296 | @classproperty 297 | def name(cls): 298 | # Return the namespaced name of the element. 299 | return '{%s}%s' % (cls.meta.namespace[1], cls.meta.name) 300 | 301 | def prepare(self): 302 | """Prepare the date in the instance state for serialization. 303 | """ 304 | 305 | # Create a collection for the attributes and elements of 306 | # this instance. 307 | attributes, elements = OrderedDict(), [] 308 | 309 | # Initialize the namespace map. 310 | nsmap = dict([self.meta.namespace]) 311 | 312 | # Iterate through all declared items. 313 | for name, item in self._items.items(): 314 | if isinstance(item, Attribute): 315 | # Prepare the item as an attribute. 316 | attributes[name] = item.prepare(self) 317 | 318 | elif isinstance(item, Element): 319 | # Update the nsmap. 320 | nsmap.update([item.namespace]) 321 | 322 | # Prepare the item as an element. 323 | elements.append(item) 324 | 325 | # Return the collected attributes and elements 326 | return attributes, elements, nsmap 327 | 328 | def _serialize_item(self, item): 329 | # Destructure the data. 330 | attributes, elements, nsmap = item.prepare() 331 | 332 | # Create the XML node. 333 | node = etree.Element(item.name, nsmap=nsmap) 334 | 335 | # Add the attributes. 336 | for name, value in attributes.items(): 337 | if value is not None: 338 | node.attrib[name] = value 339 | 340 | # Set its text. 341 | node.text = item.text 342 | 343 | # Iterate and serialize all elements. 344 | for element in elements: 345 | self._serialize_element(element, node) 346 | 347 | # Return the node. 348 | return node 349 | 350 | def _serialize_element(self, element, parent=None): 351 | # Prepare the instance state for serialization. 352 | items = element.prepare(self) 353 | if not items: 354 | # No data to serialize; move along. 355 | return 356 | 357 | try: 358 | # Serialize the item(s). 359 | for item in items: 360 | parent.append(item.serialize()) 361 | 362 | except TypeError: 363 | # Serialize the single item. 364 | parent.append(items.serialize()) 365 | 366 | def serialize(self): 367 | """ 368 | Serializes the data in the instance state as an 369 | XML representation. 370 | """ 371 | 372 | # Serialize the root and return the serialized element. 373 | return self._serialize_item(self) 374 | 375 | def tostring(self): 376 | return etree.tostring(self.serialize()) 377 | 378 | @classmethod 379 | def deserialize(cls, xml): 380 | # Instantiate an instance of ourself. 381 | instance = cls() 382 | 383 | # Set the text element if present. 384 | if xml.text: 385 | instance.text = xml.text 386 | 387 | # Iterate through the items and deserialize them on the instance. 388 | elements = iter(xml.getchildren()) 389 | element = None 390 | index = 0 391 | items = list(cls._items.values()) 392 | # print(items) 393 | while index < len(items): 394 | # Fetch the next item. 395 | item = items[index] 396 | index += 1 397 | 398 | if isinstance(item, Attribute): 399 | # Attempt to get the attribute from the 400 | # xml element. 401 | value = xml.attrib.get(item.name) 402 | 403 | # Clean the value using the item clean. 404 | value = item.clean(value) 405 | 406 | # Set it on the instance. 407 | item.__set__(instance, value) 408 | 409 | elif isinstance(item, Element): 410 | if element is None: 411 | try: 412 | # Get the next element in the chain. 413 | element = next(elements) 414 | 415 | except StopIteration: 416 | break 417 | 418 | # Resolve the element into a schema object. 419 | obj = _element_registry.get(element.tag) 420 | if obj is None: 421 | # Element is unknown; bail. 422 | element = None 423 | index -= 1 424 | continue 425 | 426 | # Is this element a subclass of the current item? 427 | if not issubclass(obj, item.type): 428 | # Nope; skip to the next item. 429 | continue 430 | 431 | # Deserialize the element. 432 | value = obj.deserialize(element) 433 | 434 | # Set it on the instance. 435 | item.__set__(instance, value) 436 | 437 | # Are we dealing with a "collection" ? 438 | if item.collection: 439 | index -= 1 440 | 441 | # Unset the current element reference. 442 | element = None 443 | 444 | # Return the deserialized instance. 445 | return instance 446 | 447 | @classmethod 448 | def fromstring(cls, text): 449 | return cls.deserialize(etree.XML(text)) 450 | -------------------------------------------------------------------------------- /saml/schema/saml.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from uuid import uuid4 3 | from datetime import datetime 4 | from . import types, base, VERSION 5 | Element = base.Element 6 | 7 | 8 | class Base(base.Base): 9 | 10 | class Meta: 11 | namespace = 'saml', 'urn:oasis:names:tc:SAML:2.0:assertion' 12 | 13 | 14 | class BaseID(Base): 15 | """Provides an extension point for identifiers. 16 | """ 17 | 18 | # The security or administrative domain that qualifies the name. 19 | name_qualifier = base.Attribute(types.String) 20 | 21 | # Further qualifies a name with the name of a service provider. 22 | sp_name_qualifier = base.Attribute(types.String, name="SPNameQualifier") 23 | 24 | 25 | class NameID(BaseID): 26 | """ 27 | Is the identifier used in various SAML assertion 28 | constructs [saml-core § 2.2.3]. 29 | """ 30 | 31 | class Format: 32 | # URI prefix for the values in this enumeration. 33 | _PREFIX = "urn:oasis:names:tc:SAML:" 34 | 35 | # The interpretation is left to individual implementations. 36 | UNSPECIFIED = '%s1.1:nameid-format:unspecified' % _PREFIX 37 | 38 | # Indicates a form of an email address. 39 | EMAIL = '%s1.1:nameid-format:emailAddress' % _PREFIX 40 | 41 | # Is in the form specified by the X.509 recommendation [XMLSig]. 42 | X509 = '%s1.1:nameid-format:X509SubjectName' % _PREFIX 43 | 44 | # Is in the format of a Windows domain qualified name. 45 | WINDOWS = '%s1.1:nameid-format:WindowsDomainQualifiedName' % _PREFIX 46 | 47 | # Is in the form of a Kerberos principal name. 48 | KEREBOS = '%s2.0:nameid-format:kerberos' % _PREFIX 49 | 50 | # Is the identifier of an entity that provides SAML-based services. 51 | ENTITY = '%s2.0:nameid-format:entity' % _PREFIX 52 | 53 | # Is a persistent opaque identifier for a principal. 54 | PERSISTENT = '%s2.0:nameid-format:persistent' % _PREFIX 55 | 56 | # Is an identifier with transient semantics. 57 | TRANSIENT = '%s2.0:nameid-format:transient' % _PREFIX 58 | 59 | # A URI classification of string-based identifier information. 60 | format = base.Attribute(types.String) 61 | 62 | # A name identifier established by a service provider. 63 | sp_provided_id = base.Attribute(types.String, name="SPProvidedID") 64 | 65 | 66 | class Issuer(NameID): 67 | """ 68 | Represents the issuer of a SAML assertion or protocol 69 | message [saml-core § 2.2.5]. 70 | """ 71 | 72 | # If no Format value is provided, then the value ENTITY is in effect. 73 | format = base.Attribute(types.String) 74 | 75 | 76 | class _Message(Base): 77 | """Contains common information found in most SAML/2.0 communications. 78 | """ 79 | 80 | # The version of this message. 81 | version = base.Attribute(types.String, default=VERSION, required=True) 82 | 83 | # The identifier for this message. 84 | id = base.Attribute(types.String, name='ID', required=True, 85 | default=lambda: '_%s' % uuid4().hex) 86 | 87 | # The time instant of issue in UTC. 88 | issue_instant = base.Attribute(types.DateTime, required=True, 89 | default=datetime.utcnow) 90 | 91 | # The SAML authority that is making the claim(s) in the message. 92 | issuer = Element(Issuer, required=True) 93 | 94 | 95 | class Statement(Base): 96 | """An extension point for statements made about a subject. 97 | """ 98 | 99 | 100 | class AuthenticationContextReference(Statement): 101 | """ 102 | A URI reference identifying an authentication context class that 103 | describes the authentication context declaration that follows. 104 | """ 105 | 106 | class Meta: 107 | name = 'AuthnContextClassRef' 108 | 109 | # URI prefix for the values in this enumeration. 110 | _PREFIX = "urn:oasis:names:tc:SAML:2.0:ac:classes:" 111 | 112 | # Authenticated through the use of a provided IP address. 113 | INTERNET_PROTOCOL = "{}InternetProtocol".format(_PREFIX) 114 | 115 | # Authenticated through a provided IP address and username/password. 116 | INTERNET_PROTOCOL_PASSWORD = "{}InternetProtocolPassword".format( 117 | _PREFIX) 118 | 119 | # Authenticated using a Kerberos ticket. 120 | KERBEROS = "{}Kerberos".format(_PREFIX) 121 | 122 | # Reflects no mobile customer registration procedures and an 123 | # authentication of the mobile device without requiring explicit 124 | # end-user interaction. 125 | MOBILE_ONE_FACTOR_UNREGISTERED = ( 126 | "{}MobileOneFactorUnregistered".format(_PREFIX)) 127 | 128 | # Reflects no mobile customer registration procedures and a 129 | # two-factor based authentication, such as secure device and user PIN. 130 | MOBILE_TWO_FACTOR_UNREGISTERED = ( 131 | "{}MobileTwoFactorUnregistered".format(_PREFIX)) 132 | 133 | # Reflects mobile contract customer registration procedures and a 134 | # single factor authentication. 135 | MOBILE_ONE_FACTOR_CONTRACT = "{}MobileOneFactorContract".format( 136 | _PREFIX) 137 | 138 | # Reflects mobile contract customer registration procedures and a 139 | # two-factor based authentication. 140 | MOBILE_TWO_FACTOR_CONTRACT = "{}MobileTwoFactorContract".format( 141 | _PREFIX) 142 | 143 | # Authenticates through the presentation of a password over an 144 | # unprotected HTTP session. 145 | PASSWORD = "{}Password".format(_PREFIX) 146 | 147 | # Authentication through the presentation of a password over a 148 | # protected session. 149 | PASSWORD_PROTECTED_TRANSPORT = "{}PasswordProtectedTransport".format( 150 | _PREFIX) 151 | 152 | # A principal had authenticated to an authentication authority at 153 | # some point in the past using any authentication context supported 154 | # by that authentication authority. 155 | PREVIOUS_SESSION = "{}PreviousSession".format(_PREFIX) 156 | 157 | # The principal authenticated by means of a digital signature where 158 | # the key was validated as part of an X.509 Public Key Infrastructure. 159 | X509 = "{}X509".format(_PREFIX) 160 | 161 | # The principal authenticated by means of a digital signature where 162 | # key was validated as part of a PGP Public Key Infrastructure. 163 | PGP = "{}PGP".format(_PREFIX) 164 | 165 | # The principal authenticated by means of a digital signature where 166 | # the key was validated via an SPKI Infrastructure. 167 | SPKI = "{}SPKI".format(_PREFIX) 168 | 169 | # This context class indicates that the principal authenticated by 170 | # means of a digital signature according to the processing rules 171 | # specified in the XML Digital Signature specification [XMLSig]. 172 | XMLDSIG = "{}XMLDSig".format(_PREFIX) 173 | 174 | # A principal authenticates to an authentication authority using a 175 | # smartcard. 176 | SMARTCARD = "{}Smartcard".format(_PREFIX) 177 | 178 | # A principal authenticates to an authentication authority through 179 | # a two-factor authentication mechanism using a smartcard with 180 | # enclosed private key and a PIN. 181 | SMARTCARD_PKI = "{}SmartcardPKI".format(_PREFIX) 182 | 183 | # A principal uses an X.509 certificate stored in software to 184 | # authenticate to the authentication authority. 185 | SOFTWARE_PKI = "{}SoftwarePKI".format(_PREFIX) 186 | 187 | # The principal authenticated via the provision of a fixed-line 188 | # telephone number, transported via a telephony protocol such as ADSL. 189 | TELEPHONY = "{}Telephony".format(_PREFIX) 190 | 191 | # The principal is "roaming" (perhaps using a phone card) and 192 | # authenticates via the means of the line number, a user suffix, 193 | # and a password element. 194 | NOMAD_TELEPHONY = "{}NomadTelephony".format(_PREFIX) 195 | 196 | # The principal authenticated via the provision of a fixed-line 197 | # telephone number and a user suffix, transported via a telephony 198 | # protocol such as ADSL. 199 | PERSONAL_TELEPHONY = "{}PersonalTelephony".format(_PREFIX) 200 | 201 | # Authenticated via the means of the line number, 202 | # a user suffix, and a password element. 203 | AUTHENTICATED_TELEPHONY = "{}AuthenticatedTelephony".format(_PREFIX) 204 | 205 | # Authenticated by means of Secure Remote Password s[RFC 2945]. 206 | SECURE_REMOTE_PASSWORD = "{}SecureRemotePassword".format(_PREFIX) 207 | 208 | # The principal authenticated by means of a client certificate, 209 | # secured with the SSL/TLS transport. 210 | TLS_CLIENT = "{}TLSClient".format(_PREFIX) 211 | 212 | # A principal authenticates through a time synchronization token. 213 | TIME_SYNC_TOKEN = "{}TimeSyncToken".format(_PREFIX) 214 | 215 | # Indicates that the authentication means are unspecified. 216 | UNSPECIFIED = "{}Unspecified".format(_PREFIX) 217 | 218 | 219 | class AuthenticationContext(Base): 220 | """Specifies the context of an authentication event. 221 | """ 222 | 223 | class Meta: 224 | name = 'AuthnContext' 225 | 226 | # A URI reference identifying an authentication context class that 227 | # describes the authentication context declaration that follows. 228 | reference = Element(AuthenticationContextReference, 229 | default=AuthenticationContextReference.UNSPECIFIED) 230 | 231 | # TODO: or 232 | # TODO: 233 | 234 | 235 | class AttributeValue(Base): 236 | pass 237 | 238 | 239 | class Attribute(Base): 240 | 241 | name_ = base.Attribute(types.String, name="Name") 242 | 243 | name_format = base.Attribute(types.String) 244 | 245 | value = Element(AttributeValue) 246 | 247 | 248 | class AttributeStatement(Statement): 249 | 250 | attributes = Element(Attribute, collection=True) 251 | 252 | 253 | class SubjectLocality(Base): 254 | """ 255 | Specifies the DNS domain name and IP address for the system from 256 | which the assertion subject was authenticated. 257 | """ 258 | 259 | 260 | class AuthenticationStatement(Statement): 261 | """ 262 | Describes a statement by the SAML authority asserting that the assertion 263 | subject was authenticated by a particular means at a particular time. 264 | """ 265 | 266 | class Meta: 267 | name = 'AuthnStatement' 268 | 269 | # Specifies the time at which the authentication took place. 270 | authn_instant = base.Attribute(types.DateTime, required=True, 271 | default=datetime.utcnow) 272 | 273 | # Specifies the index of a particular session between the principal 274 | # identified by the subject and the authenticating authority. 275 | session_index = base.Attribute(types.String) 276 | 277 | # Specifies a time instant at which the session between the principal 278 | # identified by the subject and the SAML authority issuing this 279 | # statement MUST be considered ended. 280 | session_not_on_or_after = base.Attribute(types.DateTime) 281 | 282 | # Specifies the DNS domain name and IP address for the system from which 283 | # the assertion subject was apparently authenticated. 284 | subject_locality = Element(SubjectLocality) 285 | 286 | # The context used by the authenticating authority up to and including 287 | # the authentication event that yielded this statement. 288 | context = Element(AuthenticationContext) 289 | 290 | 291 | class SubjectConfirmationData(Base): 292 | """Specifies constraints on allowing a subject to be confirmed. 293 | """ 294 | 295 | # TODO: 296 | # TODO: Arbitrary attributes 297 | # TODO: Arbitrary elements 298 | 299 | # A time instant before which the subject cannot be confirmed. 300 | not_before = base.Attribute(types.DateTime) 301 | 302 | # A time instant at which the subject can no longer be confirmed. 303 | not_on_or_after = base.Attribute(types.DateTime) 304 | 305 | # URI specifying to which an attesting entity can present the assertion. 306 | recipient = base.Attribute(types.String) 307 | 308 | # ID of a SAML message to the entity can present the assertion to. 309 | in_response_to = base.Attribute(types.String) 310 | 311 | # The network address to which the saml entity can present the assertion. 312 | address = base.Attribute(types.String) 313 | 314 | 315 | class SubjectConfirmation(Base): 316 | """ 317 | Provides the means for a relying party to verify the 318 | correspondence of the subject of the assertion with the party with whom 319 | the relying party is communicating. 320 | """ 321 | 322 | class Method: 323 | 324 | # URI namespace prefix of the values. 325 | _PREFIX = 'urn:oasis:names:tc:SAML:2.0:cm:' 326 | 327 | # The subject is confirmed by the indicated data. 328 | BEARER = '{}bearer'.format(_PREFIX) 329 | 330 | # The subject is confirmed by the holding of a key. 331 | HOLDER_OF_KEY = '{}holder-of-key'.format(_PREFIX) 332 | 333 | # URI reference that identifies a protocol to confirm the subject. 334 | method = base.Attribute(types.String, required=True, default=Method.BEARER) 335 | 336 | # Identifies the entity expected to satisfy the enclosed requirements. 337 | principal = Element(NameID) 338 | 339 | # Confirmation information and constraints. 340 | data = Element(SubjectConfirmationData) 341 | 342 | 343 | class Subject(Base): 344 | """The principal that is the subject of all statements in an assertion. 345 | """ 346 | 347 | # Identifies the subject. 348 | principal = Element(NameID) 349 | 350 | # Information that allows the subject to be confirmed. If more than one 351 | # subject confirmation is provided, then satisfying any one of them is 352 | # sufficient to confirm the subject for the purpose of applying 353 | # the assertion. 354 | confirmation = Element(SubjectConfirmation, collection=True) 355 | 356 | 357 | class Condition(Base): 358 | """Serves as an extension point for new conditions. 359 | """ 360 | 361 | 362 | class Conditions(Base): 363 | """ 364 | Defines the SAML constructs that place constraints on the 365 | acceptable use of SAML assertions. 366 | """ 367 | 368 | # A time instant before which the subject cannot be confirmed. 369 | not_before = base.Attribute(types.DateTime) 370 | 371 | # A time instant at which the subject can no longer be confirmed. 372 | not_on_or_after = base.Attribute(types.DateTime) 373 | 374 | # Specifies that the assertion is addressed to a particular audience. 375 | condition = Element(Condition, collection=True) 376 | 377 | 378 | class Audience(Base): 379 | """A URI reference that identifies an intended audience. 380 | """ 381 | 382 | 383 | class AudienceRestriction(Condition): 384 | """ 385 | Specifies that the assertion is addressed to one or more 386 | specific audiences. 387 | """ 388 | 389 | audiences = Element(Audience, collection=True) 390 | 391 | 392 | class OneTimeUse(Condition): 393 | """ 394 | Allows an authority to indicate that the information 395 | in the assertion is likely to change very soon and fresh information 396 | should be obtained for each use. 397 | """ 398 | 399 | 400 | class Assertion(_Message): 401 | """ 402 | This type specifies the basic information that is common to 403 | all assertions [saml-core § 2.3.3]. 404 | """ 405 | 406 | # The subject of the statement(s) in the assertion. 407 | subject = Element(Subject) 408 | 409 | # Conditions that MUST be evaluated when assessing the validity 410 | # of and/or when using the assertion. 411 | conditions = Element(Conditions) 412 | 413 | # Additional information related to the assertion that assists 414 | # processing in certain situations but which MAY be ignored by 415 | # applications that do not understand the advice or do not wish to 416 | # make use of it. 417 | # TODO: 418 | 419 | # Statements that are being asserted about the included subject. 420 | statements = Element(Statement, collection=True) 421 | -------------------------------------------------------------------------------- /saml/schema/samlp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import types, base, Element, saml 3 | 4 | 5 | class Base(base.Base): 6 | 7 | class Meta: 8 | namespace = 'samlp', 'urn:oasis:names:tc:SAML:2.0:protocol' 9 | 10 | 11 | class _Message(saml._Message, Base): 12 | """Contains common information found in most SAML/2.0 protocols. 13 | """ 14 | 15 | # A URI reference indicating the address to which this request has 16 | # been sent. 17 | destination = base.Attribute(types.String) 18 | 19 | # Indicates whether or not (and under what conditions) consent has 20 | # been obtained from a principal in the sending of this request. 21 | consent = base.Attribute(types.String) 22 | 23 | 24 | class NameIDPolicy(Base): 25 | """ 26 | Tailors the name identifier in the subjects of assertions 27 | resulting from an . 28 | """ 29 | 30 | # Specifies the URI reference corresponding to a name identifier format 31 | # defined in this or another specification. 32 | format = base.Attribute(types.String) 33 | 34 | # Optionally specifies that the assertion subject's identifier be 35 | # returned (or created) in the namespace of a service provider 36 | # other than the requester, or in the namespace of an affiliation 37 | # group of service providers 38 | sp_name_qualifier = base.Attribute(types.String, name='SPNameQualifier') 39 | 40 | # A Boolean value used to indicate whether the identity provider is 41 | # allowed, in the course of fulfilling the request, to create a 42 | # new identifier to represent the principal. 43 | allow_create = base.Attribute(types.Boolean) 44 | 45 | 46 | class RequestedAuthenticationContext(Base): 47 | 48 | class Meta: 49 | name = 'RequestedAuthnContext' 50 | 51 | # Specifies the comparison method used to evaluate the requested 52 | # context classes or statements, one of "exact", "minimum", 53 | # "maximum", or "better". The default is "exact". 54 | comparison = base.Attribute(types.String) 55 | 56 | # Specifies one or more URI references identifying authentication 57 | # context classes or declarations. 58 | reference = Element(saml.AuthenticationContextReference, collection=True) 59 | 60 | 61 | class Protocol: 62 | """ 63 | The available URIs that identifies a SAML protocol binding to be used when 64 | returning the message. 65 | """ 66 | 67 | _PREFIX = 'urn:oasis:names:tc:SAML:2.0:bindings:' 68 | 69 | SOAP = '%sSOAP' % _PREFIX 70 | 71 | POAS = '%sPAOS' % _PREFIX 72 | 73 | REDIRECT = '%sHTTP-Redirect' % _PREFIX 74 | 75 | POST = '%sHTTP-POST' % _PREFIX 76 | 77 | ARTIFACT = '%sHTTP-Artifact' % _PREFIX 78 | 79 | URI = '%sURI' % _PREFIX 80 | 81 | 82 | class AuthenticationRequest(_Message): 83 | """ 84 | Create a SAML AuthnRequest 85 | :: 86 | from saml import schema 87 | from datetime import datetime 88 | 89 | document = schema.AuthenticationRequest() 90 | document.id = '11111111-2222-3333-4444-555555555555' 91 | document.issue_instant = datetime(2000, 1, 1) 92 | document.assertion_consumer_service_index = 0 93 | document.attribute_consuming_service_index = 0 94 | document.issuer = 'https://sp.example.com/SAML2' 95 | 96 | policy = schema.NameIDPolicy() 97 | policy.allow_create = True 98 | policy.format = schema.NameID.Format.TRANSIENT 99 | document.policy = policy 100 | 101 | print document.tostring() 102 | 103 | Produces the following XML document: 104 | 105 | .. code-block:: xml 106 | 107 | 115 | https://sp.example.com/SAML2 116 | 119 | 120 | """ 121 | 122 | class Meta: 123 | name = 'AuthnRequest' 124 | 125 | # A Boolean value. If "true", the identity provider MUST authenticate 126 | # the presenter directly rather than rely on a previous security context. 127 | force_authn = base.Attribute(types.Boolean) 128 | 129 | # A Boolean value. If "true", the identity provider and the user agent 130 | # itself MUST NOT visibly take control of the user interface from the 131 | # requester and interact with the presenter in a noticeable fashion. 132 | is_passive = base.Attribute(types.Boolean) 133 | 134 | # A URI reference that identifies a SAML protocol binding to be used when 135 | # returning the message. 136 | protocol = base.Attribute(types.String, name='ProtocolBinding') 137 | 138 | # Indirectly identifies the location to which the message 139 | # should be returned to the requester. 140 | assertion_consumer_service_index = base.Attribute(types.Integer) 141 | 142 | # Specifies by value the location to which the message MUST 143 | # be returned to the requester. 144 | assertion_consumer_service_url = base.Attribute( 145 | types.String, name='AssertionConsumerServiceURL') 146 | 147 | # Indirectly identifies information associated with the requester 148 | # describing the SAML attributes the requester desires or requires to be 149 | # supplied by the identity provider in the message. 150 | attribute_consuming_service_index = base.Attribute(types.Integer) 151 | 152 | # Specifies the human-readable name of the requester for use by the 153 | # presenter's user agent or the identity provider. 154 | provider_name = base.Attribute(types.String) 155 | 156 | # Specifies the requested subject of the resulting assertion(s). 157 | subject = Element(saml.Subject) 158 | 159 | # Specifies constraints on the name identifier to be used to represent 160 | # the requested subject. 161 | policy = Element(NameIDPolicy) 162 | 163 | # Specifies the requirements, if any, that the requester places on 164 | # the authentication context that applies to the responding provider's 165 | # authentication of the presenter. 166 | requested_context = Element(RequestedAuthenticationContext) 167 | 168 | # Specifies a set of identity providers trusted by the requester to 169 | # authenticate the presenter, as well as limitations and context 170 | # related to proxying of the message to subsequent identity 171 | # providers by the responder. 172 | # TODO: scoping = Element(Scoping) 173 | 174 | 175 | class StatusCode(Base): 176 | 177 | # URI prefix for the values in this enumeration. 178 | _PREFIX = "urn:oasis:names:tc:SAML:2.0:status:" 179 | 180 | # The request succeeded. 181 | SUCCESS = "{}Success".format(_PREFIX) 182 | 183 | # Failure due to an error on the part of the requester. 184 | REQUESTER = "{}Requester".format(_PREFIX) 185 | 186 | # The version of the request message was incorrect. 187 | VERSION_MISMATCH = "{}VersionMismatch".format(_PREFIX) 188 | 189 | # The provider wasn't able to successfully authenticate the principal. 190 | AUTHENTICATION_FAILED = "{}AuthnFailed".format(_PREFIX) 191 | 192 | # Unexpected or invalid content was encountered. 193 | INVALID_ATTRIBUTE_NAME_OR_VALUE = "{}InvalidAttrNameOrValue".format( 194 | _PREFIX) 195 | 196 | # The responding provider cannot support the requested name ID policy. 197 | INVALID_NAME_ID_POLICY = "{}InvalidNameIDPolicy".format(_PREFIX) 198 | 199 | # The authentication context requirements cannot be met. 200 | NO_AUTHENTICATION_CONTEXT = "{}NoAuthnContext".format(_PREFIX) 201 | 202 | # None of the supported identity providers are available. 203 | NO_AVAILABLE_IDP = "{}NoAvailableIDP".format(_PREFIX) 204 | 205 | # The responding provider cannot authenticate the principal passively. 206 | NO_PASSIVE = "{}NoPassive".format(_PREFIX) 207 | 208 | # None of the identity providers are supported by the intermediary. 209 | NO_SUPPORTED_IDP = "{}NoSupportedIDP".format(_PREFIX) 210 | 211 | # Not able to propagate logout to all other session participants. 212 | PARTIAL_LOGOUT = "{}PartialLogout".format(_PREFIX) 213 | 214 | # Cannot authenticate directly and not permitted to proxy the request. 215 | PROXY_COUNT_EXCEEDED = "{}ProxyCountExceeded".format(_PREFIX) 216 | 217 | # Is able to process the request but has chosen not to respond. 218 | REQUEST_DENIED = "{}RequestDenied".format(_PREFIX) 219 | 220 | # The SAML responder or SAML authority does not support the request. 221 | REQUEST_UNSUPPORTED = "{}RequestUnsupported".format(_PREFIX) 222 | 223 | # Deprecated protocol version specified in the request. 224 | REQUEST_VERSION_DEPRECATED = "{}RequestVersionDeprecated".format( 225 | _PREFIX) 226 | 227 | # Protocol version specified in the request message is too low. 228 | REQUEST_VERSION_TOO_LOW = "{}RequestVersionTooHigh".format(_PREFIX) 229 | 230 | # Protocol version specified in the request message is too high. 231 | REQUEST_VERSION_TOO_HIGH = "{}RequestVersionTooLow".format(_PREFIX) 232 | 233 | # Resource value provided in the request message is invalid. 234 | RESOURCE_NOT_RECOGNIZED = "{}ResourceNotRecognized".format(_PREFIX) 235 | 236 | # The response message would contain more elements than able. 237 | TOO_MANY_RESPONSES = "{}TooManyResponses".format(_PREFIX) 238 | 239 | # base.Attribute from an unknown attribute profile. 240 | UNKNOWN_ATTR_PROFILE = "{}UnknownAttrProfile".format(_PREFIX) 241 | 242 | # The responder does not recognize the principal. 243 | UNKNOWN_PRINCIPAL = "{}UnknownPrincipal".format(_PREFIX) 244 | 245 | # The SAML responder cannot properly fulfill the request. 246 | UNSUPPORTED_BINDING = "{}UnsupportedBinding".format(_PREFIX) 247 | 248 | # The status code value. This attribute contains a URI reference. 249 | value = base.Attribute(types.String) 250 | 251 | # A subordinate status code that provides more specific information 252 | # on an error condition. 253 | # TODO: code = Element('self') 254 | 255 | 256 | class Status(Base): 257 | 258 | # A code representing the status of the activity carried out in 259 | # response to the corresponding request. 260 | code = Element(StatusCode, required=True) 261 | 262 | 263 | class StatusResponse(_Message): 264 | """Extends the common message type with a status element. 265 | """ 266 | 267 | # A reference to the identifier of the request to which the 268 | # response corresponds, if any. 269 | in_response_to = base.Attribute(types.String) 270 | 271 | # A code representing the status of the corresponding request. 272 | status = Element(Status) 273 | 274 | 275 | class Response(StatusResponse): 276 | """ 277 | Create a SAML Response 278 | :: 279 | from saml import schema 280 | from datetime import datetime 281 | 282 | document = schema.Response() 283 | document.id = '11111111-1111-1111-1111-111111111111' 284 | document.in_response_to = '22222222-2222-2222-2222-222222222222' 285 | document.issue_instant = datetime(2000, 1, 1, 1) 286 | document.issuer = 'https://idp.example.org/SAML2' 287 | document.destination = 'https://sp.example.com/SAML2/SSO/POST' 288 | document.status.code.value = schema.StatusCode.SUCCESS 289 | 290 | # Create an assertion for the response. 291 | document.assertions = assertion = schema.Assertion() 292 | assertion.id = '33333333-3333-3333-3333-333333333333' 293 | assertion.issue_instant = datetime(2000, 1, 1, 2) 294 | assertion.issuer = 'https://idp.example.org/SAML2' 295 | 296 | # Create a subject. 297 | assertion.subject = schema.Subject() 298 | assertion.subject.principal = '44444444-4444-4444-4444-444444444444' 299 | assertion.subject.principal.format = schema.NameID.Format.TRANSIENT 300 | data = schema.SubjectConfirmationData() 301 | data.in_response_to = '22222222-2222-2222-2222-222222222222' 302 | data.not_on_or_after = datetime(2000, 1, 1, 1, 10) 303 | data.recipient = 'https://sp.example.com/SAML2/SSO/POST' 304 | confirmation = schema.SubjectConfirmation() 305 | confirmation.data = data 306 | assertion.subject.confirmation = confirmation 307 | 308 | # Create an authentication statement. 309 | statement = schema.AuthenticationStatement() 310 | assertion.statements.append(statement) 311 | statement.authn_instant = datetime(2000, 1, 1, 1, 3) 312 | statement.session_index = '33333333-3333-3333-3333-333333333333' 313 | reference = schema.AuthenticationContextReference 314 | statement.context.reference = reference.PASSWORD_PROTECTED_TRANSPORT 315 | 316 | # Create a authentication condition. 317 | assertion.conditions = conditions = schema.Conditions() 318 | conditions.not_before = datetime(2000, 1, 1, 1, 3) 319 | conditions.not_on_or_after = datetime(2000, 1, 1, 1, 9) 320 | condition = schema.AudienceRestriction() 321 | condition.audiences = 'https://sp.example.com/SAML2' 322 | conditions.condition = condition 323 | 324 | print document.tostring() 325 | 326 | Produces the following XML document: 327 | 328 | .. code-block:: xml 329 | 330 | 338 | https://idp.example.org/SAML2 339 | 340 | 342 | 343 | 347 | https://idp.example.org/SAML2 348 | 349 | 352 | 44444444-4444-4444-4444-444444444444 353 | 354 | 356 | 361 | 362 | 363 | 366 | 367 | 368 | https://sp.example.com/SAML2 369 | 370 | 371 | 372 | 375 | 376 | 377 | urn:oasis:names:tc:SAML:2.0:ac:classes: 378 | PasswordProtectedTransport 379 | 380 | 381 | 382 | 383 | 384 | """ 385 | 386 | # Specifies an assertion by value, or optionally an encrypted assertion 387 | # by value. 388 | assertions = Element(saml.Assertion, collection=True) 389 | 390 | 391 | class Artifact(Base): 392 | """ 393 | The artifact value that the requester received and now wishes to 394 | translate into the protocol message it represents. 395 | """ 396 | 397 | 398 | class ArtifactResolve(_Message): 399 | """ 400 | Used to request that a SAML protocol message be returned in an 401 | message by specifying an artifact that represents 402 | the SAML protocol message. 403 | """ 404 | 405 | # The artifact value that the requester received and now wishes to 406 | # translate into the protocol message it represents. 407 | artifact = Element(Artifact, required=True) 408 | 409 | 410 | class ArtifactResponse(StatusResponse): 411 | """ 412 | Responds to an request with an embedded message 413 | that was referenced by the given artifact. 414 | """ 415 | 416 | # The embedded message. 417 | message = Element(_Message) 418 | 419 | 420 | class SessionIndex(Base): 421 | """The identifier that indexes a session at the message recipient. 422 | """ 423 | 424 | 425 | class LogoutRequest(_Message): 426 | """ 427 | Create a SAML LogoutRequest 428 | :: 429 | from saml import schema 430 | from datetime import datetime 431 | 432 | document = schema.LogoutRequest() 433 | document.id = '11111111-1111-1111-1111-111111111111' 434 | document.issue_instant = datetime(2000, 1, 1) 435 | document.issuer = 'https://idp.example.org/SAML2' 436 | document.destination = 'https://sp.example.org/SAML2/logout' 437 | document.principal = 'myemail@mydomain.com' 438 | document.principal.format = schema.NameID.Format.EMAIL 439 | document.principal.name_qualifier = 'https://idp.example.org/SAML2' 440 | document.session_index = 'SESSION-22222222-2222-2222-2222-222222222222' 441 | 442 | print document.tostring() 443 | 444 | Produces the following XML document: 445 | 446 | .. code-block:: xml 447 | 448 | 455 | https://idp.example.org/SAML2 456 | 459 | myemail@mydomain.com 460 | 461 | 462 | SESSION-22222222-2222-2222-2222-222222222222 463 | 464 | 465 | """ 466 | 467 | # The time at which the request expires, after which the recipient 468 | # may discard the message. 469 | not_on_or_after = base.Attribute(types.DateTime) 470 | 471 | # An indication of the reason for the logout, in the 472 | # form of a URI reference. 473 | reason = base.Attribute(types.String) 474 | 475 | # The identifier and associated attributes 476 | # (in plaintext or encrypted form) that specify the principal as 477 | # currently recognized by the identity and service providers prior 478 | # to this request. 479 | principal = Element(saml.NameID, required=True) 480 | 481 | # The identifier that indexes this session at the message recipient. 482 | session_index = Element(SessionIndex) 483 | 484 | 485 | class LogoutResponse(StatusResponse): 486 | """ 487 | Create a SAML LogoutResponse 488 | :: 489 | from saml import schema 490 | from datetime import datetime 491 | 492 | document = schema.LogoutResponse() 493 | document.id = '22222222-2222-2222-2222-222222222222' 494 | document.in_response_to = '11111111-1111-1111-1111-111111111111' 495 | document.issue_instant = datetime(2000, 1, 1) 496 | document.issuer = 'https://idp.example.org/SAML2' 497 | document.destination = 'https://sp.example.com/SAML2/SLO/POST' 498 | document.status.code.value = schema.StatusCode.SUCCESS 499 | 500 | print document.tostring() 501 | 502 | Produces the following XML document: 503 | 504 | .. code-block:: xml 505 | 506 | 514 | https://idp.example.org/SAML2 515 | 516 | 518 | 519 | 520 | """ 521 | --------------------------------------------------------------------------------