Repository: busykoala/fastapi-opa (language: python)

Issue:
Add SAML authentication
It would be great to have a SAML auth handler as there is one for OIDB already.

The new auth flow also needs to be documented for the use with the package.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 3fdd5c8..f08db74 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -16,11 +16,13 @@ jobs:
       uses: actions/setup-python@v2
       with:
         python-version: ${{ matrix.python-version }}
+    - name: Install system deps
+      run: sudo apt-get -y install libxmlsec1-dev pkg-config
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
         python -m pip install poetry
-        python -m poetry install --extras "graphql"
+        python -m poetry install --extras "graphql" --extras "saml"
     - name: Codestyle Checks
       run: |
         python -m poetry run flake8 .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86cc4d0..1e22c4a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Change Log
 
+## [1.2.0] - 2021-05-29
+
+- Add saml authentication as an authentication method.
+- Adapt authentication interface (backwards compatible) to allow async and
+  request usage within the authentication method.
+
 ## [1.1.0] - 2021-05-06
 
 - Allow custom injectables to enrich the payload sent to OPA.
diff --git a/README.md b/README.md
index 517076e..1a789ee 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,25 @@
 # Open Policy Agent Middleware for FastAPI
 
+## Table of Contents
+- [What does fastapi-opa do](#about)
+- [Installation](#installation)
+- [How to get started](#getting-started)
+- [Open Policy Agent](#opa)
+- [Authentication Flow](#auth-flow)
+  - [OIDC Authentication](#oidc-auth)
+  - [SAML Authentication](#saml-auth)
+- [Custom Payload Enrichment](#custom-payload-enrichment)
+  - [Graphql Enrichment](#gql-enrichment)
+
+<a name="about"/>
+
+## What does fastapi-opa do
+
 `fastapi-opa` is an extension to FastAPI that allows you to add a login flow
 to your application within minutes using open policy agent and your favourite
 identity provider.
 
-```bash
-─▄████▄▄░
-▄▀█▀▐└─┐░░         FastAPI App
-█▄▐▌▄█▄┘██  ---->  @app.get("/")
-└▄▄▄▄▄┘███         async def root():  
-██▒█▒███▀              return {}
-   User                   |                   Identity Provider
-    |       ------------------------------>    (e.g. Keycloak)
-    |       <------------------------------           |
-    |       ---->         |                           |
-    |                     |           ---->           |
-    |                     |           <----           |
-    |                     |           ---------------------------------->  Open Policy Agent
-    |                     |           <---------------------------------- 
-    |       <-----        |
-```
+![Flow Diagram](./assets/diagram.png)
 
 When a user tries to get a response from an endpoint he/she will be redirected
 to the identity provider for authorization.
@@ -27,12 +27,16 @@ After the authentication the app validates the token provided. Once it was
 validated the user information is used to get an OPA decision whether
 the user is allowed to get any information from the endpoint.
 
+<a name="installation"/>
+
 ## Installation
 
 ```bash
-poetry add [--extras "graphql"] fastapi-opa 
+poetry add [--extras "graphql"] [--extras "saml"] fastapi-opa 
 ```
 
+<a name="getting-started"/>
+
 ## How to get started
 
 :bulb: Checkout the wiki for a complete environment setup with Keycloak and Open Policy Agent:  
@@ -76,6 +80,8 @@ async def root() -> Dict:
     }
 ```
 
+<a name="opa"/>
+
 ## Open Policy Agent
 
 The (validated/authenticated) user token is sent to the Open Policy Agent
@@ -141,17 +147,57 @@ allow {
 }
 ```
 
-## Authentication Flow
-
-These flows are implemented:
+<a name="auth-flow"/>
 
-- OpenID Connect
+## Authentication Flow
 
-If your favourite flow is not provided yet, there is an interface provided to
-easily implement it and inject it into OPAMiddleware
+There is an interface provided to easily implement the desired authentication
+flow and inject it into OPAMiddleware
 (`fastapi_opa.auth.auth_interface.AuthInterface`), or you can open a pull
 request if you would like to contribute to the package.
 
+Also there are implementations ready to use.
+
+<a name="oidc-auth"/>
+
+### OIDC Authentication
+
+The example in [How to get started](#getting-started) provides an example for
+the implementation of the OIDC Authentication.
+
+<a name="saml-auth"/>
+
+### SAML Authentication
+
+For the saml implementation create your certs using
+`openssl req -new -x509 -days 3652 -nodes -out sp.crt -keyout sp.key` and
+add the keys to the sp section of your `settings.json`. Checkout the test
+settings to get an idea (`tests/test_data/saml/*.json`). The path to your
+own `settings.json` and `advanced_settings.json` has to be provided in the
+`SAMLAuthConfig` like in the example below (do not use the test data in
+production).
+
+```python
+from fastapi_opa import OPAConfig
+from fastapi_opa.auth.auth_saml import SAMLAuthentication
+from fastapi_opa.auth.auth_saml import SAMLConfig
+
+opa_host = "http://localhost:8181"
+
+saml_config = SAMLConfig(settings_directory="./tests/test_data/saml")
+saml_auth = SAMLAuthentication(saml_config)
+
+opa_config = OPAConfig(authentication=saml_auth, opa_host=opa_host)
+```
+
+The cert has to be uploaded to your identity provider. Using Keycloak as an
+idp you need to configure `encrypt assertion`, `client signature required`,
+`force POST bindings` on creating the client.
+Also configure: `Client Scopes` -> `role_list (saml)` -> `Mappers tab` ->
+`role list` -> `Single Role Attribute`
+
+<a name="custom-payload-enrichment"/>
+
 ## Custom Payload Enrichment
 
 In `fastapi_opa.opa.opa_config.Injectable` an interface is provided to add
@@ -171,9 +217,10 @@ opa_config = OPAConfig(
 )
 ```
 
-:bulb: For GraphQL there is a ready to use injectable in
-`fastapi_opa.opa.enrichment.graphql_enrichment.GraphQLInjectable`
 
-## Roadmap
+<a name="gql-enrichment"/>
+
+### Graphql Enrichment
 
-- Add other authentication flows
+For GraphQL there is a ready to use injectable in
+`fastapi_opa.opa.enrichment.graphql_enrichment.GraphQLInjectable`
diff --git a/assets/diagram.png b/assets/diagram.png
new file mode 100644
index 0000000..03001bb
Binary files /dev/null and b/assets/diagram.png differ
diff --git a/fastapi_opa/auth/auth_interface.py b/fastapi_opa/auth/auth_interface.py
index ff88c0e..2ed08c7 100644
--- a/fastapi_opa/auth/auth_interface.py
+++ b/fastapi_opa/auth/auth_interface.py
@@ -3,7 +3,7 @@
 from typing import Dict
 from typing import Union
 
-from starlette.requests import HTTPConnection
+from starlette.requests import Request
 from starlette.responses import RedirectResponse
 
 
@@ -13,27 +13,11 @@ class AuthInterface(ABC):
     """
 
     @abstractmethod
-    def authenticate(
-        self, *args: object, **kwargs: object
+    async def authenticate(
+        self, request: Request
     ) -> Union[RedirectResponse, Dict]:
         """The method returns a dictionary containing the valid and authorized
         users information or a redirect since some flows require calling a
         identity broker beforehand.
         """
         pass
-
-
-class OIDCAuthenticationInterface(AuthInterface):
-    """The interface provides the necessary interface for the oidc
-    authentication flow.
-    """
-
-    @abstractmethod
-    def authenticate(
-        self, connection: HTTPConnection
-    ) -> Union[RedirectResponse, Dict]:
-        """The method returns a dictionary containing the valid and authorized
-        users information or a redirect since OIDC requires to call the id
-        broker beforehand.
-        """
-        pass
diff --git a/fastapi_opa/auth/auth_oidc.py b/fastapi_opa/auth/auth_oidc.py
index bd1b28a..3600687 100644
--- a/fastapi_opa/auth/auth_oidc.py
+++ b/fastapi_opa/auth/auth_oidc.py
@@ -14,10 +14,10 @@
 import requests
 from jwt.exceptions import DecodeError
 from jwt.exceptions import InvalidTokenError
-from starlette.requests import HTTPConnection
+from starlette.requests import Request
 from starlette.responses import RedirectResponse
 
-from fastapi_opa.auth.auth_interface import OIDCAuthenticationInterface
+from fastapi_opa.auth.auth_interface import AuthInterface
 from fastapi_opa.auth.exceptions import OIDCException
 
 logger = logging.getLogger(__name__)
@@ -41,7 +41,7 @@ class OIDCConfig:
     get_user_info: bool = field(default=False)
 
 
-class OIDCAuthentication(OIDCAuthenticationInterface):
+class OIDCAuthentication(AuthInterface):
     def __init__(self, config: OIDCConfig) -> None:
         self.config = config
         if self.config.well_known_endpoint:
@@ -73,20 +73,20 @@ def set_from_well_known(self):
         if self.config.get_user_info and not self.userinfo_endpoint:
             raise OIDCException("Userinfo endpoint not provided")
 
-    def authenticate(
-        self, connection: HTTPConnection
+    async def authenticate(
+        self, request: Request
     ) -> Union[RedirectResponse, Dict]:
         callback_uri = urlunparse(
             [
-                connection.url.scheme,
-                connection.url.netloc,
-                connection.url.path,
+                request.url.scheme,
+                request.url.netloc,
+                request.url.path,
                 "",
                 "",
                 "",
             ]
         )
-        code = connection.query_params.get("code")
+        code = request.query_params.get("code")
 
         # redirect to id provider if code query-value is not present
         if not code:
diff --git a/fastapi_opa/auth/auth_saml.py b/fastapi_opa/auth/auth_saml.py
new file mode 100644
index 0000000..3ff4ce8
--- /dev/null
+++ b/fastapi_opa/auth/auth_saml.py
@@ -0,0 +1,85 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict
+from typing import Union
+
+from onelogin.saml2.auth import OneLogin_Saml2_Auth
+from onelogin.saml2.utils import OneLogin_Saml2_Utils
+from starlette.requests import Request
+from starlette.responses import RedirectResponse
+
+from fastapi_opa.auth.auth_interface import AuthInterface
+from fastapi_opa.auth.exceptions import SAMLException
+
+
+@dataclass
+class SAMLConfig:
+    settings_directory: str
+
+
+class SAMLAuthentication(AuthInterface):
+    def __init__(self, config: SAMLConfig):
+        self.config = config
+        self.custom_folder = Path(self.config.settings_directory)
+
+    async def authenticate(
+        self, request: Request
+    ) -> Union[RedirectResponse, Dict]:
+        request_args = await self.prepare_request(request)
+        auth = await self.init_saml_auth(request_args)
+
+        if "acs" in request.query_params:
+            return await self.assertion_consumer_service(auth, request_args)
+        # potentially extend with logout here
+        return await self.single_sign_on(auth)
+
+    async def init_saml_auth(self, request_args: Dict) -> OneLogin_Saml2_Auth:
+        return OneLogin_Saml2_Auth(
+            request_args, custom_base_path=self.custom_folder.as_posix()
+        )
+
+    @staticmethod
+    async def single_sign_on(auth: OneLogin_Saml2_Auth) -> RedirectResponse:
+        redirect_url = auth.login()
+        return RedirectResponse(redirect_url)
+
+    @staticmethod
+    async def assertion_consumer_service(
+        auth: OneLogin_Saml2_Auth, request_args: Dict
+    ) -> Union[RedirectResponse, Dict]:
+        auth.process_response()
+        errors = auth.get_errors()
+        if not len(errors) == 0:
+            raise SAMLException()
+        userdata = {
+            "samlUserdata": auth.get_attributes(),
+            "samlNameId": auth.get_nameid(),
+            "samlNameIdFormat": auth.get_nameid_format(),
+            "samlNameIdNameQualifier": auth.get_nameid_nq(),
+            "samlNameIdSPNameQualifier": auth.get_nameid_spnq(),
+            "samlSessionIndex": auth.get_session_index(),
+        }
+
+        self_url = OneLogin_Saml2_Utils.get_self_url(request_args)
+        if "RelayState" in request_args.get("post_data") and self_url.rstrip(
+            "/"
+        ) != request_args.get("post_data", {}).get("RelayState").rstrip("/"):
+            return RedirectResponse(
+                auth.redirect_to(
+                    request_args.get("post_data", {}).get("RelayState")
+                )
+            )
+        else:
+            return userdata
+
+    @staticmethod
+    async def prepare_request(request: Request):
+        return {
+            "https": "on" if request.url.scheme == "https" else "off",
+            "http_host": request.url.hostname,
+            "server_port": request.url.port,
+            "script_name": request.url.path,
+            "post_data": await request.form()
+            # Uncomment if using ADFS
+            # "lowercase_urlencoding": True
+        }
diff --git a/fastapi_opa/auth/exceptions.py b/fastapi_opa/auth/exceptions.py
index 8c79779..1ef32e0 100644
--- a/fastapi_opa/auth/exceptions.py
+++ b/fastapi_opa/auth/exceptions.py
@@ -8,3 +8,9 @@ class OIDCException(AuthenticationException):
     """OIDC authentication flow exception."""
 
     pass
+
+
+class SAMLException(AuthenticationException):
+    """SAML authentication flow exception."""
+
+    pass
diff --git a/fastapi_opa/opa/opa_middleware.py b/fastapi_opa/opa/opa_middleware.py
index 47c00f6..47f5473 100644
--- a/fastapi_opa/opa/opa_middleware.py
+++ b/fastapi_opa/opa/opa_middleware.py
@@ -1,10 +1,10 @@
+import asyncio
 import json
 import logging
 from json.decoder import JSONDecodeError
 
 import requests
 from fastapi.responses import JSONResponse
-from starlette.requests import HTTPConnection
 from starlette.requests import Request
 from starlette.responses import RedirectResponse
 from starlette.types import ASGIApp
@@ -26,13 +26,14 @@ def __init__(self, app: ASGIApp, config: OPAConfig) -> None:
     async def __call__(
         self, scope: Scope, receive: Receive, send: Send
     ) -> None:
+        request = Request(scope, receive, send)
         # authenticate user or get redirect to identity provider
         try:
             user_info_or_auth_redirect = (
-                self.config.authentication.authenticate(  # noqa
-                    HTTPConnection(scope)
-                )
+                self.config.authentication.authenticate(request)
             )
+            if asyncio.iscoroutine(user_info_or_auth_redirect):
+                user_info_or_auth_redirect = await user_info_or_auth_redirect
         except AuthenticationException:
             logger.error("AuthenticationException raised on login")
             return await self.get_unauthorized_response(scope, receive, send)
@@ -45,7 +46,6 @@ async def __call__(
         # Check OPA decision for info provided in user_info
         is_authorized = False
         # Enrich user_info if injectables are provided
-        request = Request(scope, receive, send)
         if self.config.injectables:
             for injectable in self.config.injectables:
                 user_info_or_auth_redirect[
diff --git a/poetry.lock b/poetry.lock
index f7e976a..649ee41 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -24,17 +24,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "attrs"
-version = "20.3.0"
+version = "21.2.0"
 description = "Classes Without Boilerplate"
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
-docs = ["furo", "sphinx", "zope.interface"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
 
 [[package]]
 name = "authlib"
@@ -117,11 +117,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "click"
-version = "7.1.2"
+version = "8.0.1"
 description = "Composable command line interface toolkit"
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 
 [[package]]
 name = "colorama"
@@ -158,6 +162,14 @@ category = "main"
 optional = false
 python-versions = ">=3.6, <3.7"
 
+[[package]]
+name = "defusedxml"
+version = "0.6.0"
+description = "XML bomb protection for Python stdlib modules"
+category = "main"
+optional = true
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
 [[package]]
 name = "fastapi"
 version = "0.63.0"
@@ -214,7 +226,7 @@ smmap = ">=3.0.1,<5"
 
 [[package]]
 name = "gitpython"
-version = "3.1.15"
+version = "3.1.17"
 description = "Python Git Library"
 category = "dev"
 optional = false
@@ -222,7 +234,7 @@ python-versions = ">=3.5"
 
 [package.dependencies]
 gitdb = ">=4.0.1,<5"
-typing-extensions = ">=3.7.4.0"
+typing-extensions = {version = ">=3.7.4.0", markers = "python_version < \"3.8\""}
 
 [[package]]
 name = "graphene"
@@ -283,7 +295,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "importlib-metadata"
-version = "4.0.1"
+version = "4.3.1"
 description = "Read metadata from Python packages"
 category = "dev"
 optional = false
@@ -305,6 +317,17 @@ category = "dev"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "isodate"
+version = "0.6.0"
+description = "An ISO 8601 date/time/duration parser and formatter"
+category = "main"
+optional = true
+python-versions = "*"
+
+[package.dependencies]
+six = "*"
+
 [[package]]
 name = "isort"
 version = "5.8.0"
@@ -318,6 +341,20 @@ pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
 requirements_deprecated_finder = ["pipreqs", "pip-api"]
 colors = ["colorama (>=0.4.3,<0.5.0)"]
 
+[[package]]
+name = "lxml"
+version = "4.6.3"
+description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
+category = "main"
+optional = true
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
+
+[package.extras]
+cssselect = ["cssselect (>=0.7)"]
+html5 = ["html5lib"]
+htmlsoup = ["beautifulsoup4"]
+source = ["Cython (>=0.29.7)"]
+
 [[package]]
 name = "mccabe"
 version = "0.6.1"
@@ -347,6 +384,14 @@ category = "dev"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "nest-asyncio"
+version = "1.5.1"
+description = "Patch asyncio to allow nested event loops"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
 [[package]]
 name = "packaging"
 version = "20.9"
@@ -428,7 +473,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "pydantic"
-version = "1.7.3"
+version = "1.7.4"
 description = "Data validation and settings management using python 3.6 type hinting"
 category = "main"
 optional = false
@@ -497,9 +542,23 @@ toml = "*"
 [package.extras]
 testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
 
+[[package]]
+name = "pytest-asyncio"
+version = "0.15.1"
+description = "Pytest support for asyncio."
+category = "dev"
+optional = false
+python-versions = ">= 3.6"
+
+[package.dependencies]
+pytest = ">=5.4.0"
+
+[package.extras]
+testing = ["coverage", "hypothesis (>=5.7.1)"]
+
 [[package]]
 name = "pytest-mock"
-version = "3.6.0"
+version = "3.6.1"
 description = "Thin-wrapper around the mock package for easier use with pytest"
 category = "dev"
 optional = false
@@ -522,6 +581,34 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 [package.dependencies]
 six = ">=1.5"
 
+[[package]]
+name = "python-multipart"
+version = "0.0.5"
+description = "A streaming multipart parser for Python"
+category = "main"
+optional = true
+python-versions = "*"
+
+[package.dependencies]
+six = ">=1.4.0"
+
+[[package]]
+name = "python3-saml"
+version = "1.10.1"
+description = "Onelogin Python Toolkit. Add SAML support to your Python software using this library"
+category = "main"
+optional = true
+python-versions = "*"
+
+[package.dependencies]
+defusedxml = "0.6.0"
+isodate = ">=0.5.0"
+lxml = ">=3.3.5"
+xmlsec = ">=1.0.5"
+
+[package.extras]
+test = ["coverage (>=4.5.2)", "coveralls (1.5.1)", "flake8 (3.6.0)", "freezegun (0.3.11)", "pylint (1.9.4)"]
+
 [[package]]
 name = "pyyaml"
 version = "5.4.1"
@@ -629,16 +716,27 @@ python-versions = "*"
 
 [[package]]
 name = "urllib3"
-version = "1.26.4"
+version = "1.26.5"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
 
 [package.extras]
+brotli = ["brotlipy (>=0.6.0)"]
 secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
 socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
-brotli = ["brotlipy (>=0.6.0)"]
+
+[[package]]
+name = "xmlsec"
+version = "1.3.11"
+description = "Python bindings for the XML Security Library"
+category = "main"
+optional = true
+python-versions = ">=3.5"
+
+[package.dependencies]
+lxml = ">=3.8"
 
 [[package]]
 name = "zipp"
@@ -654,11 +752,12 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt
 
 [extras]
 graphql = ["graphene"]
+saml = ["python3-saml", "python-multipart"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6"
-content-hash = "ef09080157dc9d6497bfee76cb21e34b74a08133bce6dc3035a0d28f09db9b5d"
+content-hash = "b9d797d9bb98ccbc84d1a8201f791e3998f0109fde9c559d28b341752acd88db"
 
 [metadata.files]
 aniso8601 = [
@@ -674,8 +773,8 @@ atomicwrites = [
     {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
 ]
 attrs = [
-    {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
-    {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
+    {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
+    {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
 ]
 authlib = [
     {file = "Authlib-0.15.3-py2.py3-none-any.whl", hash = "sha256:0f6af3a38d37dd77361808dd3f2e258b647668dac6d2cefcefc4c4ebc3c7d2b2"},
@@ -736,8 +835,8 @@ chardet = [
     {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
 ]
 click = [
-    {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
-    {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
+    {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
+    {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
 ]
 colorama = [
     {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@@ -761,6 +860,10 @@ dataclasses = [
     {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"},
     {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"},
 ]
+defusedxml = [
+    {file = "defusedxml-0.6.0-py2.py3-none-any.whl", hash = "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93"},
+    {file = "defusedxml-0.6.0.tar.gz", hash = "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5"},
+]
 fastapi = [
     {file = "fastapi-0.63.0-py3-none-any.whl", hash = "sha256:98d8ea9591d8512fdadf255d2a8fa56515cdd8624dca4af369da73727409508e"},
     {file = "fastapi-0.63.0.tar.gz", hash = "sha256:63c4592f5ef3edf30afa9a44fa7c6b7ccb20e0d3f68cd9eba07b44d552058dcb"},
@@ -778,8 +881,8 @@ gitdb = [
     {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"},
 ]
 gitpython = [
-    {file = "GitPython-3.1.15-py3-none-any.whl", hash = "sha256:a77824e516d3298b04fb36ec7845e92747df8fcfee9cacc32dd6239f9652f867"},
-    {file = "GitPython-3.1.15.tar.gz", hash = "sha256:05af150f47a5cca3f4b0af289b73aef8cf3c4fe2385015b06220cbcdee48bb6e"},
+    {file = "GitPython-3.1.17-py3-none-any.whl", hash = "sha256:29fe82050709760081f588dd50ce83504feddbebdc4da6956d02351552b1c135"},
+    {file = "GitPython-3.1.17.tar.gz", hash = "sha256:ee24bdc93dce357630764db659edaf6b8d664d4ff5447ccfeedd2dc5c253f41e"},
 ]
 graphene = [
     {file = "graphene-2.1.8-py2.py3-none-any.whl", hash = "sha256:09165f03e1591b76bf57b133482db9be6dac72c74b0a628d3c93182af9c5a896"},
@@ -798,17 +901,69 @@ idna = [
     {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
 ]
 importlib-metadata = [
-    {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"},
-    {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"},
+    {file = "importlib_metadata-4.3.1-py3-none-any.whl", hash = "sha256:c2e27fa8b6c8b34ebfcd4056ae2ca290e36250d1fbeceec85c1c67c711449fac"},
+    {file = "importlib_metadata-4.3.1.tar.gz", hash = "sha256:2d932ea08814f745863fd20172fe7de4794ad74567db78f2377343e24520a5b6"},
 ]
 iniconfig = [
     {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
     {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
 ]
+isodate = [
+    {file = "isodate-0.6.0-py2.py3-none-any.whl", hash = "sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"},
+    {file = "isodate-0.6.0.tar.gz", hash = "sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8"},
+]
 isort = [
     {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"},
     {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"},
 ]
+lxml = [
+    {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"},
+    {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"},
+    {file = "lxml-4.6.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d"},
+    {file = "lxml-4.6.3-cp27-cp27m-win32.whl", hash = "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106"},
+    {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"},
+    {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"},
+    {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"},
+    {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"},
+    {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"},
+    {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"},
+    {file = "lxml-4.6.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16"},
+    {file = "lxml-4.6.3-cp35-cp35m-win32.whl", hash = "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2"},
+    {file = "lxml-4.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4"},
+    {file = "lxml-4.6.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4"},
+    {file = "lxml-4.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3"},
+    {file = "lxml-4.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d"},
+    {file = "lxml-4.6.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24"},
+    {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec"},
+    {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617"},
+    {file = "lxml-4.6.3-cp36-cp36m-win32.whl", hash = "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04"},
+    {file = "lxml-4.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a"},
+    {file = "lxml-4.6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654"},
+    {file = "lxml-4.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0"},
+    {file = "lxml-4.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3"},
+    {file = "lxml-4.6.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96"},
+    {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2"},
+    {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92"},
+    {file = "lxml-4.6.3-cp37-cp37m-win32.whl", hash = "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade"},
+    {file = "lxml-4.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b"},
+    {file = "lxml-4.6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa"},
+    {file = "lxml-4.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a"},
+    {file = "lxml-4.6.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927"},
+    {file = "lxml-4.6.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e"},
+    {file = "lxml-4.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791"},
+    {file = "lxml-4.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae"},
+    {file = "lxml-4.6.3-cp38-cp38-win32.whl", hash = "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28"},
+    {file = "lxml-4.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7"},
+    {file = "lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0"},
+    {file = "lxml-4.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1"},
+    {file = "lxml-4.6.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23"},
+    {file = "lxml-4.6.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59"},
+    {file = "lxml-4.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969"},
+    {file = "lxml-4.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a"},
+    {file = "lxml-4.6.3-cp39-cp39-win32.whl", hash = "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f"},
+    {file = "lxml-4.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83"},
+    {file = "lxml-4.6.3.tar.gz", hash = "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468"},
+]
 mccabe = [
     {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
     {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
@@ -821,6 +976,10 @@ mypy-extensions = [
     {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
     {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
 ]
+nest-asyncio = [
+    {file = "nest_asyncio-1.5.1-py3-none-any.whl", hash = "sha256:76d6e972265063fe92a90b9cc4fb82616e07d586b346ed9d2c89a4187acea39c"},
+    {file = "nest_asyncio-1.5.1.tar.gz", hash = "sha256:afc5a1c515210a23c461932765691ad39e8eba6551c055ac8d5546e69250d0aa"},
+]
 packaging = [
     {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
     {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
@@ -853,28 +1012,28 @@ pycparser = [
     {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
 ]
 pydantic = [
-    {file = "pydantic-1.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c59ea046aea25be14dc22d69c97bee629e6d48d2b2ecb724d7fe8806bf5f61cd"},
-    {file = "pydantic-1.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a4143c8d0c456a093387b96e0f5ee941a950992904d88bc816b4f0e72c9a0009"},
-    {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:d8df4b9090b595511906fa48deda47af04e7d092318bfb291f4d45dfb6bb2127"},
-    {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:514b473d264671a5c672dfb28bdfe1bf1afd390f6b206aa2ec9fed7fc592c48e"},
-    {file = "pydantic-1.7.3-cp36-cp36m-win_amd64.whl", hash = "sha256:dba5c1f0a3aeea5083e75db9660935da90216f8a81b6d68e67f54e135ed5eb23"},
-    {file = "pydantic-1.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59e45f3b694b05a69032a0d603c32d453a23f0de80844fb14d55ab0c6c78ff2f"},
-    {file = "pydantic-1.7.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:5b24e8a572e4b4c18f614004dda8c9f2c07328cb5b6e314d6e1bbd536cb1a6c1"},
-    {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:b2b054d095b6431cdda2f852a6d2f0fdec77686b305c57961b4c5dd6d863bf3c"},
-    {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:025bf13ce27990acc059d0c5be46f416fc9b293f45363b3d19855165fee1874f"},
-    {file = "pydantic-1.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:6e3874aa7e8babd37b40c4504e3a94cc2023696ced5a0500949f3347664ff8e2"},
-    {file = "pydantic-1.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e682f6442ebe4e50cb5e1cfde7dda6766fb586631c3e5569f6aa1951fd1a76ef"},
-    {file = "pydantic-1.7.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:185e18134bec5ef43351149fe34fda4758e53d05bb8ea4d5928f0720997b79ef"},
-    {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:f5b06f5099e163295b8ff5b1b71132ecf5866cc6e7f586d78d7d3fd6e8084608"},
-    {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:24ca47365be2a5a3cc3f4a26dcc755bcdc9f0036f55dcedbd55663662ba145ec"},
-    {file = "pydantic-1.7.3-cp38-cp38-win_amd64.whl", hash = "sha256:d1fe3f0df8ac0f3a9792666c69a7cd70530f329036426d06b4f899c025aca74e"},
-    {file = "pydantic-1.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6864844b039805add62ebe8a8c676286340ba0c6d043ae5dea24114b82a319e"},
-    {file = "pydantic-1.7.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ecb54491f98544c12c66ff3d15e701612fc388161fd455242447083350904730"},
-    {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:ffd180ebd5dd2a9ac0da4e8b995c9c99e7c74c31f985ba090ee01d681b1c4b95"},
-    {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8d72e814c7821125b16f1553124d12faba88e85405b0864328899aceaad7282b"},
-    {file = "pydantic-1.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:475f2fa134cf272d6631072554f845d0630907fce053926ff634cc6bc45bf1af"},
-    {file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"},
-    {file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"},
+    {file = "pydantic-1.7.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3c60039e84552442defbcb5d56711ef0e057028ca7bfc559374917408a88d84e"},
+    {file = "pydantic-1.7.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6e7e314acb170e143c6f3912f93f2ec80a96aa2009ee681356b7ce20d57e5c62"},
+    {file = "pydantic-1.7.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:8ef77cd17b73b5ba46788d040c0e820e49a2d80cfcd66fda3ba8be31094fd146"},
+    {file = "pydantic-1.7.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:115d8aa6f257a1d469c66b6bfc7aaf04cd87c25095f24542065c68ebcb42fe63"},
+    {file = "pydantic-1.7.4-cp36-cp36m-win_amd64.whl", hash = "sha256:66757d4e1eab69a3cfd3114480cc1d72b6dd847c4d30e676ae838c6740fdd146"},
+    {file = "pydantic-1.7.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4c92863263e4bd89e4f9cf1ab70d918170c51bd96305fe7b00853d80660acb26"},
+    {file = "pydantic-1.7.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3b8154babf30a5e0fa3aa91f188356763749d9b30f7f211fafb247d4256d7877"},
+    {file = "pydantic-1.7.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:80cc46378505f7ff202879dcffe4bfbf776c15675028f6e08d1d10bdfbb168ac"},
+    {file = "pydantic-1.7.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:dda60d7878a5af2d8560c55c7c47a8908344aa78d32ec1c02d742ede09c534df"},
+    {file = "pydantic-1.7.4-cp37-cp37m-win_amd64.whl", hash = "sha256:4c1979d5cc3e14b35f0825caddea5a243dd6085e2a7539c006bc46997ef7a61a"},
+    {file = "pydantic-1.7.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8857576600c32aa488f18d30833aa833b54a48e3bab3adb6de97e463af71f8f8"},
+    {file = "pydantic-1.7.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1f86d4da363badb39426a0ff494bf1d8510cd2f7274f460eee37bdbf2fd495ec"},
+    {file = "pydantic-1.7.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:3ea1256a9e782149381e8200119f3e2edea7cd6b123f1c79ab4bbefe4d9ba2c9"},
+    {file = "pydantic-1.7.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:e28455b42a0465a7bf2cde5eab530389226ce7dc779de28d17b8377245982b1e"},
+    {file = "pydantic-1.7.4-cp38-cp38-win_amd64.whl", hash = "sha256:47c5b1d44934375a3311891cabd450c150a31cf5c22e84aa172967bf186718be"},
+    {file = "pydantic-1.7.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:00250e5123dd0b123ff72be0e1b69140e0b0b9e404d15be3846b77c6f1b1e387"},
+    {file = "pydantic-1.7.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d24aa3f7f791a023888976b600f2f389d3713e4f23b7a4c88217d3fce61cdffc"},
+    {file = "pydantic-1.7.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2c44a9afd4c4c850885436a4209376857989aaf0853c7b118bb2e628d4b78c4e"},
+    {file = "pydantic-1.7.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:e87edd753da0ca1d44e308a1b1034859ffeab1f4a4492276bff9e1c3230db4fe"},
+    {file = "pydantic-1.7.4-cp39-cp39-win_amd64.whl", hash = "sha256:a3026ee105b5360855e500b4abf1a1d0b034d88e75a2d0d66a4c35e60858e15b"},
+    {file = "pydantic-1.7.4-py3-none-any.whl", hash = "sha256:a82385c6d5a77e3387e94612e3e34b77e13c39ff1295c26e3ba664e7b98073e2"},
+    {file = "pydantic-1.7.4.tar.gz", hash = "sha256:0a1abcbd525fbb52da58c813d54c2ec706c31a91afdb75411a73dd1dec036595"},
 ]
 pyflakes = [
     {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"},
@@ -892,14 +1051,26 @@ pytest = [
     {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"},
     {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
 ]
+pytest-asyncio = [
+    {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"},
+    {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"},
+]
 pytest-mock = [
-    {file = "pytest-mock-3.6.0.tar.gz", hash = "sha256:f7c3d42d6287f4e45846c8231c31902b6fa2bea98735af413a43da4cf5b727f1"},
-    {file = "pytest_mock-3.6.0-py3-none-any.whl", hash = "sha256:952139a535b5b48ac0bb2f90b5dd36b67c7e1ba92601f3a8012678c4bd7f0bcc"},
+    {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"},
+    {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"},
 ]
 python-dateutil = [
     {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
     {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
 ]
+python-multipart = [
+    {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
+]
+python3-saml = [
+    {file = "python3-saml-1.10.1.tar.gz", hash = "sha256:336ef44f894b5e09cf339a67b007d8299096b7b44f43ee7426eae410771e4466"},
+    {file = "python3_saml-1.10.1-py2-none-any.whl", hash = "sha256:cbbea3e38a020a93fe745f59c6969bb1c60e726a49d34bbab76d03dc2bbe2a66"},
+    {file = "python3_saml-1.10.1-py3-none-any.whl", hash = "sha256:5e04d586fa7c6017ae4e00d16769d4ff04ba0b9903a2d1c1e61b6ad9e9ff23fe"},
+]
 pyyaml = [
     {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
     {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
@@ -1040,8 +1211,21 @@ typing-extensions = [
     {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
 ]
 urllib3 = [
-    {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
-    {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
+    {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"},
+    {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"},
+]
+xmlsec = [
+    {file = "xmlsec-1.3.11-cp35-cp35m-win32.whl", hash = "sha256:8922fba55a060ee81de4a7f5efc593c5bf121047763aecf0eead02e061c9d2db"},
+    {file = "xmlsec-1.3.11-cp35-cp35m-win_amd64.whl", hash = "sha256:705ab5b848afdf3a5c78b1322276054c885f44dc51601e14cb883a9c86cbe20f"},
+    {file = "xmlsec-1.3.11-cp36-cp36m-win32.whl", hash = "sha256:eadff662d89c80db409c69d82eb3e695e16d4a5e8ab56b5b22670a54e9c6ff20"},
+    {file = "xmlsec-1.3.11-cp36-cp36m-win_amd64.whl", hash = "sha256:86d54b93f8278e2f0c504d0744e39a483c1c7ce9993f2ca70184cc7770faa982"},
+    {file = "xmlsec-1.3.11-cp37-cp37m-win32.whl", hash = "sha256:dea5bef9b5830c36ccb7a68a0d94d49eaea4d03fbbd04179652bf661b7e6e30f"},
+    {file = "xmlsec-1.3.11-cp37-cp37m-win_amd64.whl", hash = "sha256:ee233d0bc27fb8f447ca2622b0de2ac2df45b8795f02ef263825912011fe4fe9"},
+    {file = "xmlsec-1.3.11-cp38-cp38-win32.whl", hash = "sha256:c7b49d4fce83186b89f7ce6cec765245d36a70d0acc2f3ed0ba95c735b3667da"},
+    {file = "xmlsec-1.3.11-cp38-cp38-win_amd64.whl", hash = "sha256:23f209260b37bdc2fd96af837494c47dd1e67964f077442b63acd83c0f62e212"},
+    {file = "xmlsec-1.3.11-cp39-cp39-win32.whl", hash = "sha256:843d10bba4c480609da74ee11fff1ee0fc1c12821c656979f12a7a4ecb043e03"},
+    {file = "xmlsec-1.3.11-cp39-cp39-win_amd64.whl", hash = "sha256:4fb38ab0bf3e47cbae136119674a869e09d61c939b510350f369c8ac46087373"},
+    {file = "xmlsec-1.3.11.tar.gz", hash = "sha256:cd2eaaff7f31784a07dd99ce81fa767313df3ba1834faa4143ee2c07000cac7a"},
 ]
 zipp = [
     {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"},
diff --git a/pyproject.toml b/pyproject.toml
index 3fd300c..2af1426 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,12 +1,12 @@
 [tool.poetry]
 name = "fastapi-opa"
-version = "1.1.0"
+version = "1.2.0"
 description = "Fastapi OPA middleware incl. auth flow."
 authors = ["Matthias Osswald <m@osswald.li>"]
 license = "GPL-3.0-or-later"
 readme = "README.md"
 repository = "https://github.com/busykoala/fastapi-opa"
-keywords = ["fastapi", "oidc", "authentication", "authorization"]
+keywords = ["fastapi", "oidc", "authentication", "authorization", "saml"]
 
 [tool.poetry.dependencies]
 python = "^3.6"
@@ -14,6 +14,8 @@ fastapi = "^0.63.0"
 requests = "^2.25.1"
 PyJWT = {extras = ["crypto"], version = "^2.0.1"}
 graphene = {version = "^2.1.8", optional = true}
+python3-saml = {version = "^1.10.1", optional = true}
+python-multipart = {version = "^0.0.5", optional = true}
 
 
 [tool.poetry.dev-dependencies]
@@ -26,9 +28,12 @@ pytest-mock = "^3.5.1"
 mock = "^4.0.3"
 freezegun = "^1.1.0"
 Authlib = "^0.15.3"
+pytest-asyncio = "^0.15.1"
+nest-asyncio = "^1.5.1"
 
 [tool.poetry.extras]
 graphql = ["graphene"]
+saml = ["python3-saml", "python-multipart"]
 
 [build-system]
 requires = ["poetry-core>=1.0.0"]


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 49c3b31..619e1ee 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,6 @@
 from typing import Dict
 
+import nest_asyncio
 import pytest
 from fastapi import FastAPI
 from fastapi.testclient import TestClient
@@ -10,6 +11,8 @@
 from tests.utils import AuthenticationDummy
 from tests.utils import OPAInjectableExample
 
+nest_asyncio.apply()
+
 
 @pytest.fixture
 def client():
@@ -51,7 +54,7 @@ async def root() -> Dict:
 
 
 @pytest.fixture
-def gql_injected_client():
+async def gql_injected_client():
     opa_host = "http://localhost:8181"
     oidc_auth = AuthenticationDummy()
     injectable = GraphQLInjectable("gql_injectable")
@@ -63,6 +66,7 @@ def gql_injected_client():
     app.add_middleware(OPAMiddleware, config=opa_config)
 
     @app.get("/")
+    @pytest.mark.asyncio
     async def root() -> Dict:
         return {
             "msg": "success",
diff --git a/tests/test_data/saml/advanced_settings.json b/tests/test_data/saml/advanced_settings.json
new file mode 100644
index 0000000..a3a094a
--- /dev/null
+++ b/tests/test_data/saml/advanced_settings.json
@@ -0,0 +1,34 @@
+{
+    "security": {
+        "nameIdEncrypted": false,
+        "authnRequestsSigned": false,
+        "logoutRequestSigned": false,
+        "logoutResponseSigned": false,
+        "signMetadata": false,
+        "wantMessagesSigned": false,
+        "wantAssertionsSigned": false,
+        "wantNameId" : true,
+        "wantNameIdEncrypted": false,
+        "wantAssertionsEncrypted": false,
+        "allowSingleLabelDomains": false,
+        "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
+        "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256"
+    },
+    "contactPerson": {
+        "technical": {
+            "givenName": "techDude",
+            "emailAddress": "tech.dude@example.com"
+        },
+        "support": {
+            "givenName": "supportDude",
+            "emailAddress": "support.girl@example.com"
+        }
+    },
+    "organization": {
+        "en-US": {
+            "name": "sp_test",
+            "displayname": "SP test",
+            "url": "http://sp.example.com"
+        }
+    }
+}
diff --git a/tests/test_data/saml/settings.json b/tests/test_data/saml/settings.json
new file mode 100644
index 0000000..dd9cd24
--- /dev/null
+++ b/tests/test_data/saml/settings.json
@@ -0,0 +1,31 @@
+{
+    "strict": true,
+    "debug": true,
+    "sp": {
+        "entityId": "example-client",
+        "assertionConsumerService": {
+            "url": "http://localhost:8000/?acs",
+            "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+        },
+        "singleLogoutService": {
+            "url": "http://localhost:8000/?sls",
+            "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+        },
+        "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
+        "__sp_keys": "Create keys and upload: 'openssl req -new -x509 -days 3652 -nodes -out sp.crt -keyout sp.key'",
+        "x509cert": "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDINZkyTH7PTWnnNKOhCsX92CMCcdHidUnv6O0e3Xz1gtq9H4JXB8s7KYvTWx9sxeRxeYsxrhhCl/W9YljNVCmPrOZrOXFTqX7m3r7FmlVZIJUVL/Y9frQbu9Zhz5/LYdDkcZqKCZzxafjP0g+EivYEwjRz1BpBXDnJ3mSKS+tDVYL79RFAC7K6ematCEPuBLfJTRzIbL5tXYtf0tipKFxZs3xPA6CBR4xqGdyhzhDhRFKwHqjXF3qJ8lPk2G+/R+101ERfTaqPgoCVCeOKt85Ip/zi9cZD108aQZhyBagNAlaYGWZv+sJigN9OSnPGx3vaDnIULefLCZyG4PTFZ7EFAgMBAAECggEBAI+GjQqX+BiYwKD1BZC/bmyp838fJX2Y46ZPbx2/XoOKiVEnM4M8nR8Oi6S7IG4T9865eWXmDpI+6fYYeNzJxvmu04hup6V4TvcnG9AA8cpxJy8HMRmoqWlwYSHBfCaLOvtpzYXDmnUeqTYiuukjzXZf2sZmeWP9v5fODHlwL3oWoDkHcuttdl4B1I7w6L19Um/nwmr1l9bbrNbL1bsMnzwwiV9RLhIstoOBUFa0BuljHmjjW9c1+nXnsJljptZFL96QXMR9nAnAlAYHiIoI87dXxZdEHFyGl840zbBBb79f9uaslrCbp4L2ufyGIdt4AnmzckrBDgMuiz78o5P3h0ECgYEA7x7KL6CxR5+VPwHLLUdbovleRhaPp0YwPptC7ohSeXnvvJhAGSeOL9bqujE1Sfsvk5zZDEvGRrqTdNq53udaLjOrAttnZgF/YnU/Tp7FCfNipSLur5sThBH1s4v4dGvNK6Mtfb1FpVdLVdgWI/kmg4K/eJidsGtIcU9tsSnX/8kCgYEA1lejf5THvwKR+BL0r2+VK+MXF+RwsliUS+pMK8hauo8I+Vpm6WbDsyrncyCBxhhzUYqi/Z5eFdpHSSJxIFA1Ha9seiV6ndU502fWy4hpIRJZrxGF4ub0C/UD097aV3fXW61hW6Fr9Lf1qOq2RAv0N/bD8Mj+5qcfBKx14GFGHV0CgYArigKsi2jcLr2BHHV/SBfyY897mY4MmiYcsC6OujmyLratM1ewfNhR86MHxf/6+YafLRcEOyPeKwjAZTck1CFCelvwCD0agLlOFyUwzo34Et3qTaMCTI1QJekECT9zC1HvWSXmWDk/MtbfyZm2cR5HId51Oey0e8RUBW9mq8TNqQKBgBEkFw4n3sV2tieplGnHOZ213m3ptd19WfasOQUzU5YhxCz0vtAhiLr2TmYVjmtCH07BEIHJvWpv901w9K0J3QWPttQZ1TF0ENjIMBQ8x3N+nS9z7koUACZIpNK3uqxOCCnrtqJ2SUTbxUiJ5KOSdShYkpjZ6hCWAtQD2rVj8hB9AoGBAIWmK1L8te9uJdXvDfBHnUBuQezu9ruSyPS71nRLB+1fal6IeBya/DbXxZOnLaAtnE5aPBsIirK5PyProCh6AR64md5icg88MW9deNk3AkPbcAsk+XyyEyyPTK+3cQ+YqVNYtW0ujfD4IqqCORme5PEus2iUdqEK+FniJF1aDq1E",
+        "privateKey": "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDINZkyTH7PTWnnNKOhCsX92CMCcdHidUnv6O0e3Xz1gtq9H4JXB8s7KYvTWx9sxeRxeYsxrhhCl/W9YljNVCmPrOZrOXFTqX7m3r7FmlVZIJUVL/Y9frQbu9Zhz5/LYdDkcZqKCZzxafjP0g+EivYEwjRz1BpBXDnJ3mSKS+tDVYL79RFAC7K6ematCEPuBLfJTRzIbL5tXYtf0tipKFxZs3xPA6CBR4xqGdyhzhDhRFKwHqjXF3qJ8lPk2G+/R+101ERfTaqPgoCVCeOKt85Ip/zi9cZD108aQZhyBagNAlaYGWZv+sJigN9OSnPGx3vaDnIULefLCZyG4PTFZ7EFAgMBAAECggEBAI+GjQqX+BiYwKD1BZC/bmyp838fJX2Y46ZPbx2/XoOKiVEnM4M8nR8Oi6S7IG4T9865eWXmDpI+6fYYeNzJxvmu04hup6V4TvcnG9AA8cpxJy8HMRmoqWlwYSHBfCaLOvtpzYXDmnUeqTYiuukjzXZf2sZmeWP9v5fODHlwL3oWoDkHcuttdl4B1I7w6L19Um/nwmr1l9bbrNbL1bsMnzwwiV9RLhIstoOBUFa0BuljHmjjW9c1+nXnsJljptZFL96QXMR9nAnAlAYHiIoI87dXxZdEHFyGl840zbBBb79f9uaslrCbp4L2ufyGIdt4AnmzckrBDgMuiz78o5P3h0ECgYEA7x7KL6CxR5+VPwHLLUdbovleRhaPp0YwPptC7ohSeXnvvJhAGSeOL9bqujE1Sfsvk5zZDEvGRrqTdNq53udaLjOrAttnZgF/YnU/Tp7FCfNipSLur5sThBH1s4v4dGvNK6Mtfb1FpVdLVdgWI/kmg4K/eJidsGtIcU9tsSnX/8kCgYEA1lejf5THvwKR+BL0r2+VK+MXF+RwsliUS+pMK8hauo8I+Vpm6WbDsyrncyCBxhhzUYqi/Z5eFdpHSSJxIFA1Ha9seiV6ndU502fWy4hpIRJZrxGF4ub0C/UD097aV3fXW61hW6Fr9Lf1qOq2RAv0N/bD8Mj+5qcfBKx14GFGHV0CgYArigKsi2jcLr2BHHV/SBfyY897mY4MmiYcsC6OujmyLratM1ewfNhR86MHxf/6+YafLRcEOyPeKwjAZTck1CFCelvwCD0agLlOFyUwzo34Et3qTaMCTI1QJekECT9zC1HvWSXmWDk/MtbfyZm2cR5HId51Oey0e8RUBW9mq8TNqQKBgBEkFw4n3sV2tieplGnHOZ213m3ptd19WfasOQUzU5YhxCz0vtAhiLr2TmYVjmtCH07BEIHJvWpv901w9K0J3QWPttQZ1TF0ENjIMBQ8x3N+nS9z7koUACZIpNK3uqxOCCnrtqJ2SUTbxUiJ5KOSdShYkpjZ6hCWAtQD2rVj8hB9AoGBAIWmK1L8te9uJdXvDfBHnUBuQezu9ruSyPS71nRLB+1fal6IeBya/DbXxZOnLaAtnE5aPBsIirK5PyProCh6AR64md5icg88MW9deNk3AkPbcAsk+XyyEyyPTK+3cQ+YqVNYtW0ujfD4IqqCORme5PEus2iUdqEK+FniJF1aDq1E"
+    },
+    "idp": {
+        "entityId": "http://localhost:8080/auth/realms/example-realm",
+        "singleSignOnService": {
+            "url": "http://localhost:8080/auth/realms/example-realm/protocol/saml",
+            "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+        },
+        "singleLogoutService": {
+            "url": "http://localhost:8080/auth/realms/example-realm/protocol/saml",
+            "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+        },
+        "x509cert": "MIICqTCCAZECBgF5kBXnuzANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1leGFtcGxlLXJlYWxtMB4XDTIxMDUyMTE3NTg0NVoXDTMxMDUyMTE4MDAyNVowGDEWMBQGA1UEAwwNZXhhbXBsZS1yZWFsbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALd1jXIMIZ6O4XD2cZv0hOiENMw64Dhk6UuELUQkhX97iYfhVyVfW/5wtdapEOquuZr17MWACeDTC4uTKKfTZ/PtvRVMsR0RVUErCWImnM4oUaqkPMrkRUpCIoWFhLTOxn7Jbmb1a/KMraXD4Va8jRqiGOoDJK6Vhxayeq+U7EMn31hjH4WQAHxX0iXmRwjhuvljCMJUEdXW69VDyLJcQIPj1tZIlXzit88U7yqEDWUtxRO53F5Kjm4f12dm2pcwH2qGUeaitxKjbZsJThSFidoqaxQMqnT5N+giUirhg5yFLILVzCZTw2MYETiY3EAOh/SecGliCABzCbaw2KwLFDsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAkpFoEgC/2oIRqQ3X9d/hQ2LdutcsqU544RnzbO+XjP1uxhDZWqqxaTDiIawe47Ikq1bU+QN1jpXEwt9zBoMAlGZ+ETZikVbiifJsGzIt07s5bdAOnAk+55qmyRJfgUoeTLEqSKUwYQzcq2CzGI9y9pFil0RMN2eTrHR4bW6fN0T52RX4ocWcQjYUMtBINJbR6B9udjedhS0cJbmUFAZ6rjQbhPC9/G6i6Tec3xRvbR0MYAC77hZqScdcvgyRD9AP2AyOwFhYyzYAT19uSw/kgxRqbDhN6teKVBNVHgNi7zZ922XS5q/SwX6YtG8ETlDZngWYQGMipNYzYtXm57JA+w=="
+    }
+}
diff --git a/tests/test_enrichment_graphql.py b/tests/test_enrichment_graphql.py
index 55324e8..add99f1 100644
--- a/tests/test_enrichment_graphql.py
+++ b/tests/test_enrichment_graphql.py
@@ -14,7 +14,8 @@ def test_query_parsing(test_payload, expected):
     assert expected == gql_analysis.operations
 
 
-def test_gql_injection(gql_injected_client):
+@pytest.mark.asyncio
+async def test_gql_injection(gql_injected_client):
     with patch("fastapi_opa.opa.opa_middleware.requests.post") as req:
         payload = GQL_TEST_CASES[0][0]
         gql_injected_client.get("/", json=payload)
diff --git a/tests/test_opa.py b/tests/test_opa.py
index 4ca159d..c7f03e5 100644
--- a/tests/test_opa.py
+++ b/tests/test_opa.py
@@ -1,5 +1,6 @@
 import json
 
+import pytest
 from mock import patch
 
 from fastapi_opa import OPAConfig
@@ -36,7 +37,8 @@ def test_successful_opa_flow(client):
     assert {"msg": "success"} == response.json()
 
 
-def test_not_allowing_opa_flow(client):
+@pytest.mark.asyncio
+async def test_not_allowing_opa_flow(client):
     with patch("fastapi_opa.opa.opa_middleware.requests.post") as req:
         req.return_value.status_code = 200
         req.return_value.json = lambda: {"result": {"allow": False}}
@@ -59,7 +61,8 @@ def test_not_allowing_opa_flow(client):
     assert {"message": "Unauthorized"} == response.json()
 
 
-def test_function_injection(injected_client):
+@pytest.mark.asyncio
+async def test_function_injection(injected_client):
     with patch("fastapi_opa.opa.opa_middleware.requests.post") as req:
         payload = {"some": "data"}
         injected_client.get("/", json=payload)
diff --git a/tests/test_saml_auth.py b/tests/test_saml_auth.py
new file mode 100644
index 0000000..1b42ecf
--- /dev/null
+++ b/tests/test_saml_auth.py
@@ -0,0 +1,70 @@
+from unittest.mock import Mock
+
+import pytest
+from mock import patch
+from starlette.responses import RedirectResponse
+
+from fastapi_opa.auth.auth_saml import SAMLAuthentication
+from fastapi_opa.auth.auth_saml import SAMLConfig
+
+
+@pytest.mark.asyncio
+async def test_single_sign_on():
+    saml_conf = SAMLConfig(settings_directory="./tests/test_data/saml")
+    saml_auth = SAMLAuthentication(saml_conf)
+
+    saml_auth_mock = Mock()
+    saml_auth_mock.login.return_value = "http://idp.com/cryptic-stuff"
+    response = await saml_auth.single_sign_on(saml_auth_mock)
+
+    assert isinstance(response, RedirectResponse)
+    assert response.headers.get("location") == "http://idp.com/cryptic-stuff"
+
+
+@pytest.mark.asyncio
+@patch("fastapi_opa.auth.auth_saml.OneLogin_Saml2_Utils")
+async def test_assertion_consumer_service(saml_util_mock):
+    saml_util_mock.get_self_url.return_value = "http://sp.com"
+    saml_conf = SAMLConfig(settings_directory="./tests/test_data/saml")
+    saml_auth = SAMLAuthentication(saml_conf)
+    saml_auth_mock = Mock()
+    saml_auth_mock.get_errors.return_value = []
+    saml_auth_mock.get_attributes.return_value = {
+        "Role": [
+            "default-roles-example-realm",
+            "uma_authorization",
+            "view-profile",
+            "manage-account",
+            "manage-account-links",
+            "offline_access",
+        ]
+    }
+    saml_auth_mock.get_nameid.return_value = "alice"
+    saml_auth_mock.get_nameid_format.return_value = (
+        "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
+    )
+    saml_auth_mock.get_nameid_nq.return_value = None
+    saml_auth_mock.get_nameid_spnq.return_value = None
+    saml_auth_mock.get_session_index.return_value = "8167416b-6a10-4a4c-889c-7574074e3fc5::f1eaf88b-2bb9-4d2e-8d3d-39587ba1ef37"  # noqa
+
+    response = await saml_auth.assertion_consumer_service(
+        saml_auth_mock, {"post_data": []}
+    )
+    expected = {
+        "samlUserdata": {
+            "Role": [
+                "default-roles-example-realm",
+                "uma_authorization",
+                "view-profile",
+                "manage-account",
+                "manage-account-links",
+                "offline_access",
+            ]
+        },
+        "samlNameId": "alice",
+        "samlNameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",  # noqa
+        "samlNameIdNameQualifier": None,
+        "samlNameIdSPNameQualifier": None,
+        "samlSessionIndex": "8167416b-6a10-4a4c-889c-7574074e3fc5::f1eaf88b-2bb9-4d2e-8d3d-39587ba1ef37",  # noqa
+    }
+    assert expected == response