├── tests ├── __init__.py ├── ias │ ├── __init__.py │ ├── ias_tokens.py │ ├── ias_configs.py │ └── test_xssec_ias.py ├── utils │ ├── __init__.py │ └── uaa_mock.py ├── jwt_tools.py ├── test_config.py ├── test_key_tools.py ├── http_responses.py ├── test_key_cache_v2.py ├── test_jwt_audience_validator.py ├── test_req_token.py ├── keys.py ├── test_key_cache.py ├── uaa_configs.py ├── jwt_payloads.py └── test_xssec.py ├── version.txt ├── setup.cfg ├── MANIFEST.in ├── sap ├── __init__.py └── xssec │ ├── security_context.py │ ├── key_tools.py │ ├── constants.py │ ├── __init__.py │ ├── jwt_validation_facade.py │ ├── key_cache_v2.py │ ├── key_cache.py │ ├── jwt_audience_validator.py │ ├── security_context_ias.py │ └── security_context_xsuaa.py ├── requirements-tests.txt ├── tox.ini ├── REUSE.toml ├── .github └── workflows │ └── main.yml ├── .gitignore ├── setup.py ├── CONTRIBUTING_USING_GENAI.md ├── CONTRIBUTING.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── README.md ├── LICENSES └── Apache-2.0.txt └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ias/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 4.2.2 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include README.md 3 | include version.txt -------------------------------------------------------------------------------- /sap/__init__.py: -------------------------------------------------------------------------------- 1 | ''' sap namespace ''' 2 | __path__ = __import__('pkgutil').extend_path(__path__, __name__) 3 | -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | cryptography 3 | flask 4 | mock 5 | parameterized 6 | pylint 7 | pytest 8 | requests 9 | respx 10 | tox 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,py312 3 | 4 | [testenv] 5 | allowlist_externals = env 6 | 7 | deps = -rrequirements-tests.txt 8 | commands = pytest tests 9 | -------------------------------------------------------------------------------- /sap/xssec/security_context.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is deprecated. 3 | """ 4 | import warnings 5 | 6 | from sap.xssec import SecurityContextXSUAA 7 | 8 | warnings.warn("Class security_context.SecurityContext is deprecated, " 9 | "use security_context_xsuaa.SecurityContextXSUAA instead ", DeprecationWarning, stacklevel=2) 10 | 11 | SecurityContext = SecurityContextXSUAA 12 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | SPDX-PackageName = "cloud-pysec" 3 | SPDX-PackageSupplier = "Allen Liu " 4 | SPDX-PackageDownloadLocation = "https://github.com/SAP/cloud-pysec" 5 | 6 | [[annotations]] 7 | path = "**" 8 | precedence = "aggregate" 9 | SPDX-FileCopyrightText = "2019-2020 SAP SE or an SAP affiliate company and cloud-pysec contributors" 10 | SPDX-License-Identifier = "Apache-2.0" 11 | -------------------------------------------------------------------------------- /sap/xssec/key_tools.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cryptography.hazmat.primitives._serialization import Encoding, PublicFormat 4 | from jwt.algorithms import RSAAlgorithm 5 | 6 | 7 | def jwk_to_pem(jwk) -> str: 8 | jwk["n"] = jwk["n"] + "==" # avoid `incorrect padding` 9 | pubkey = RSAAlgorithm.from_jwk(json.dumps(jwk)) 10 | pem = pubkey.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode() 11 | return pem 12 | -------------------------------------------------------------------------------- /tests/jwt_tools.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | 3 | from tests.keys import JWT_SIGNING_PRIVATE_KEY 4 | 5 | 6 | def sign(payload, headers=None): 7 | if headers is None: 8 | headers = { 9 | "jku": "https://api.cf.test.com", 10 | "kid": "key-id-0" 11 | } 12 | payload = {k: payload[k] for k in payload if payload[k] is not None} 13 | return jwt.encode(payload, JWT_SIGNING_PRIVATE_KEY, algorithm="RS256", headers=headers) 14 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from sap.xssec import constants 2 | from importlib import reload 3 | 4 | 5 | def test_http_timeout_overridden(monkeypatch): 6 | assert constants.HTTP_TIMEOUT_IN_SECONDS == 2 7 | 8 | monkeypatch.setenv("XSSEC_HTTP_TIMEOUT_IN_SECONDS", "10") 9 | reload(constants) 10 | assert constants.HTTP_TIMEOUT_IN_SECONDS == 10 11 | 12 | monkeypatch.delenv("XSSEC_HTTP_TIMEOUT_IN_SECONDS") 13 | reload(constants) 14 | assert constants.HTTP_TIMEOUT_IN_SECONDS == 2 15 | 16 | -------------------------------------------------------------------------------- /sap/xssec/constants.py: -------------------------------------------------------------------------------- 1 | """ constants """ 2 | import os 3 | 4 | XSAPPNAMEPREFIX = '$XSAPPNAME.' 5 | SYSTEM = 'System' 6 | JOBSCHEDULER = 'JobScheduler' 7 | HDB = 'HDB' 8 | GRANTTYPE_AUTHCODE = 'authorization_code' 9 | GRANTTYPE_PASSWORD = 'password' 10 | GRANTTYPE_CLIENTCREDENTIAL = 'client_credentials' 11 | GRANTTYPE_SAML2BEARER = 'urn:ietf:params:oauth:grant-type:saml2-bearer' 12 | GRANTTYPE_JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer' 13 | KEYCACHE_DEFAULT_CACHE_SIZE = 100 14 | KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES = 15 15 | HTTP_TIMEOUT_IN_SECONDS = int(os.getenv("XSSEC_HTTP_TIMEOUT_IN_SECONDS", 2)) 16 | HTTP_RETRY_NUMBER_RETRIES = 3 17 | HTTP_RETRY_ON_ERROR_CODE = [502, 503, 504] 18 | HTTP_RETRY_BACKOFF_FACTOR = 0.5 # retry after 0s, 1s, 2s 19 | -------------------------------------------------------------------------------- /tests/test_key_tools.py: -------------------------------------------------------------------------------- 1 | from sap.xssec.key_tools import jwk_to_pem 2 | from tests.ias.ias_configs import JWKS 3 | 4 | 5 | def test_jwk_to_pem(): 6 | expected = '-----BEGIN PUBLIC KEY-----\n' \ 7 | 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwaZIIdPySi6tYIpXBNAo\n' \ 8 | 'qvtr+uzwAaLBU+Z7JwqrYyNrk/dhcwMw47tBv36/lg2/R2QPt672/gDOKWlyecYB\n' \ 9 | 'JBt7A1trPbAlYzAi+PEbtVweOpx/6gX1t8e/ydHVNOnnY7BIpoqq6cy0itLW4n7W\n' \ 10 | 'ilxGaVfXHzOCLaX5fHNIqssAHPQWrYYkZmYPU9T/5boUfMTuhyiR9hJWInX+YEaf\n' \ 11 | 'konBQgLa8E72Naq4aUz1OR08/kOmY40Q7nIW7po7oZ4QXeT2E4QvQBLQY6bwqbLC\n' \ 12 | 'EOD6zkUZU27A0bQ7+dCfdUj2OKBe4q7Pn97Vt5xPavDzx+mLMUbjvpPAHoFAKTxJ\n' \ 13 | 'ZwIDAQAB\n' \ 14 | '-----END PUBLIC KEY-----\n' 15 | assert expected == jwk_to_pem(JWKS["keys"][0]) 16 | -------------------------------------------------------------------------------- /sap/xssec/__init__.py: -------------------------------------------------------------------------------- 1 | """ xssec """ 2 | from typing import Dict 3 | 4 | from sap.xssec.security_context_ias import SecurityContextIAS 5 | from sap.xssec.security_context_xsuaa import SecurityContextXSUAA 6 | 7 | 8 | def create_security_context_xsuaa(token, service_credentials: Dict[str, str]): 9 | """ 10 | Creates the XSUAA Security Context by validating the received access token. 11 | 12 | :param token: string containing the access_token 13 | :param service_credentials: dict containing the uaa/ias credentials 14 | :return: SecurityContextXSUAA object 15 | """ 16 | return SecurityContextXSUAA(token, service_credentials) 17 | 18 | 19 | def create_security_context_ias(token, service_credentials: Dict[str, str]): 20 | """ 21 | Creates the IAS Security Context by validating the received access token. 22 | 23 | :param token: string containing the access_token 24 | :param service_credentials: dict containing the uaa/ias credentials 25 | :return: SecurityContextIAS object 26 | """ 27 | return SecurityContextIAS(token, service_credentials) 28 | 29 | 30 | create_security_context = create_security_context_xsuaa 31 | -------------------------------------------------------------------------------- /tests/ias/ias_tokens.py: -------------------------------------------------------------------------------- 1 | from tests.jwt_tools import sign 2 | 3 | 4 | def merge(dict1, dict2): 5 | result = dict1.copy() 6 | result.update(dict2) 7 | return result 8 | 9 | 10 | HEADER = { 11 | "alg": "RS256", 12 | "kid": "kid-custom" 13 | } 14 | 15 | PAYLOAD = { 16 | "sub": "vorname.nachname@sap.com", 17 | "iss": "https://tenant.accounts400.ondemand.com", 18 | "groups": "CONFIGURED_GROUP", 19 | "given_name": "Vorname", 20 | "aud": [ 21 | "clientid" 22 | ], 23 | "user_uuid": "db60e49c-1fb7-4a15-9a9e-8ababf856fe9", 24 | "azp": "70af88d4-0371-4374-b4f5-f24f650bfac5", 25 | "zone_uuid": "4b0c2b7a-1279-4352-a68d-a9a228a4f1e9", 26 | "app_tid": "4b0c2b7a-1279-4352-a68d-a9a228a4f1e9", 27 | "iat": 1470815434, 28 | "exp": 2101535434, 29 | "family_name": "Nachname", 30 | "jti": "b23fa11e-3455-49f4-b0c3-a141e648e6ae", 31 | "email": "vorname.nachname@sap.com" 32 | } 33 | 34 | 35 | VALID_TOKEN = sign(PAYLOAD, headers=HEADER) 36 | 37 | VALID_TOKEN_WITH_CUSTOM_DOMAIN = sign(merge(PAYLOAD, { 38 | "ias_iss": "https://tenant.accounts400.ondemand.com", 39 | "iss": "https://tenant.custom.domain.com", 40 | }), headers=HEADER) 41 | 42 | TOKEN_INVALID_ISSUER = sign(merge(PAYLOAD, { 43 | "iss": "https://wrong-domain", 44 | }), headers=HEADER) 45 | 46 | TOKEN_INVALID_AUDIENCE = sign(merge(PAYLOAD, { 47 | "aud": ["wrong-client"], 48 | }), headers=HEADER) 49 | 50 | TOKEN_EXPIRED = sign(merge(PAYLOAD, { 51 | "exp": 1470815434, 52 | }), headers=HEADER) 53 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: cloud-pysec CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: 7 | - '*' 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-22.04 14 | 15 | strategy: 16 | matrix: 17 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install --upgrade virtualenv 31 | pip install -U tox importlib_metadata 32 | - name: Run tox 33 | run: | 34 | pythonVersion=${{ matrix.python-version }} 35 | pythonVersion=${pythonVersion//./} 36 | tox -e py${pythonVersion} 37 | 38 | deploy: 39 | needs: build 40 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Set up Python 45 | uses: actions/setup-python@v2 46 | with: 47 | python-version: 3.8 48 | - name: Install dependencies 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install wheel twine 52 | - name: Build and publish 53 | env: 54 | TWINE_USERNAME: __token__ 55 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 56 | run: | 57 | python setup.py sdist bdist_wheel 58 | twine upload dist/* 59 | -------------------------------------------------------------------------------- /tests/utils/uaa_mock.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,invalid-name,missing-docstring 2 | from flask import Flask, request, jsonify 3 | from sap.xssec.constants import GRANTTYPE_JWT_BEARER 4 | 5 | app = Flask(__name__) 6 | 7 | 8 | @app.before_request 9 | def only_accepts_json(): 10 | if request.headers.get('Accept') != 'application/json': 11 | response = jsonify('{"error": "Accept header is not application/json"}') 12 | response.status_code = 400 13 | return response 14 | 15 | 16 | @app.route('/') 17 | def ping(): 18 | return 'OK' 19 | 20 | 21 | @app.route('/401/oauth/token', methods=['POST']) 22 | def return_401(): 23 | return '', 401 24 | 25 | 26 | @app.route('/500/oauth/token', methods=['POST']) 27 | def return_500(): 28 | return '', 500 29 | 30 | 31 | @app.route('/correct/oauth/token', methods=['POST']) 32 | def return_token(): 33 | grant_type = request.form.get('grant_type') 34 | authorization = request.authorization 35 | if not authorization: 36 | return 'No authorization header', 401 37 | if authorization.username != 'clientid' or authorization.password != 'clientsecret': 38 | return 'Invalid authorization header', 401 39 | if grant_type != GRANTTYPE_JWT_BEARER: 40 | return 'Invalid grant type', 400 41 | return jsonify({'access_token': 'access_token'}) 42 | 43 | 44 | @app.route('/mtls/oauth/token', methods=['POST']) 45 | def return_token_mtls(): 46 | grant_type = request.form.get('grant_type') 47 | if grant_type != GRANTTYPE_JWT_BEARER: 48 | return 'Invalid grant type', 400 49 | # TODO: certificate validation 50 | return jsonify({'access_token': 'access_token'}) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | .idea/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | *.iml 28 | *.whl 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | .venv/ 84 | 85 | # virtualenv 86 | venv/ 87 | ENV/ 88 | .venv/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # IDE 97 | .vscode/ 98 | .devcontainer/ 99 | 100 | # local tests 101 | local_tests/ 102 | 103 | # Mac files 104 | .DS_Store 105 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ xssec setup """ 2 | import codecs 3 | from os import path 4 | from setuptools import setup, find_packages 5 | 6 | CURRENT_DIR = path.abspath(path.dirname(__file__)) 7 | README_LOCATION = path.join(CURRENT_DIR, 'README.md') 8 | VERSION = '' 9 | 10 | with open(path.join(CURRENT_DIR, 'version.txt'), 'r') as version_file: 11 | VERSION = version_file.read() 12 | 13 | with codecs.open(README_LOCATION, 'r', 'utf-8') as readme_file: 14 | LONG_DESCRIPTION = readme_file.read() 15 | 16 | setup( 17 | name='sap-xssec', 18 | url='https://github.com/SAP/cloud-pysec', 19 | version=VERSION.strip(), 20 | author='SAP SE', 21 | description=('SAP Python Security Library'), 22 | packages=find_packages(include=['sap*']), 23 | data_files=[('.', ['version.txt', 'CHANGELOG.md'])], 24 | test_suite='tests', 25 | install_requires=[ 26 | 'deprecation>=2.1.0', 27 | 'httpx>=0.28.1', 28 | 'urllib3', 29 | 'six>=1.11.0', 30 | 'pyjwt>=2.0.1', 31 | 'cachetools>=4.2.4', 32 | 'cryptography>=35.0.0' 33 | ], 34 | long_description=LONG_DESCRIPTION, 35 | long_description_content_type="text/markdown", 36 | classifiers=[ 37 | # http://pypi.python.org/pypi?%3Aaction=list_classifiers 38 | "Development Status :: 5 - Production/Stable", 39 | "Topic :: Security", 40 | "License :: OSI Approved :: Apache Software License", 41 | "Natural Language :: English", 42 | "Operating System :: MacOS :: MacOS X", 43 | "Operating System :: POSIX", 44 | "Operating System :: POSIX :: BSD", 45 | "Operating System :: POSIX :: Linux", 46 | "Operating System :: Microsoft :: Windows", 47 | "Programming Language :: Python", 48 | "Programming Language :: Python :: 3", 49 | "Programming Language :: Python :: 3.8", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "Programming Language :: Python :: Implementation :: CPython", 55 | "Programming Language :: Python :: Implementation :: PyPy", 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /sap/xssec/jwt_validation_facade.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import json 3 | 4 | ALGORITHMS = ['RS256'] 5 | OPTIONS = { 6 | 'verify_aud': False 7 | } 8 | 9 | 10 | class JwtValidationFacade(object): 11 | """ 12 | Hides if either sapjwt or pyjwt library is used for the validation. 13 | """ 14 | def __init__(self): 15 | self._pem = None 16 | self._payload = None 17 | self._error_desc = None 18 | self._error_code = 0 19 | 20 | def decode(self, token, verify=True): 21 | try: 22 | return jwt.decode(token, options={"verify_signature": verify}) 23 | except jwt.exceptions.DecodeError as e: 24 | raise DecodeError(e) 25 | 26 | def get_unverified_header(self, token): 27 | try: 28 | return jwt.get_unverified_header(token) 29 | except jwt.exceptions.DecodeError as e: 30 | raise DecodeError(e) 31 | 32 | def has_token_expired(self, token) -> bool: 33 | try: 34 | jwt.decode(token, options={"verify_signature": False, 'verify_exp': True}) 35 | return False 36 | except jwt.exceptions.ExpiredSignatureError as e: 37 | return True 38 | 39 | def loadPEM(self, verification_key): 40 | self._pem = verification_key 41 | return 0 42 | 43 | def checkToken(self, token): 44 | try: 45 | if "-----BEGIN PUBLIC KEY-----" in self._pem and '\n' not in self._pem: 46 | self._pem = self._pem.replace('-----BEGIN PUBLIC KEY-----', '-----BEGIN PUBLIC KEY-----\n').replace('-----END PUBLIC KEY-----','\n-----END PUBLIC KEY-----') 47 | self._payload = jwt.decode(token, self._pem, algorithms=ALGORITHMS, options=OPTIONS) 48 | self._error_desc = '' 49 | self._error_code = 0 50 | except (jwt.exceptions.InvalidTokenError, jwt.exceptions.InvalidKeyError) as e: 51 | self._error_desc = str(e) 52 | self._error_code = 1 53 | except ValueError as e: 54 | self._error_desc = str(e) 55 | self._error_code = 1 56 | 57 | def getErrorDescription(self): 58 | return self._error_desc 59 | 60 | def getErrorRC(self): 61 | return self._error_code 62 | 63 | def getJWPayload(self): 64 | return self._payload 65 | 66 | 67 | class DecodeError(Exception): 68 | pass 69 | -------------------------------------------------------------------------------- /tests/ias/ias_configs.py: -------------------------------------------------------------------------------- 1 | SERVICE_CREDENTIALS = { 2 | "clientid": "clientid", 3 | "clientsecret": "SECRET", 4 | "domain": "accounts400.ondemand.com", 5 | "url": "https://tenant.accounts400.ondemand.com", 6 | "zone_uuid": "1c3bc4e1-0d22-4497-b7bb-06c5f1494d79" 7 | } 8 | 9 | WELL_KNOWN = { 10 | "issuer": "https://tenant.accounts400.ondemand.com", 11 | "authorization_endpoint": "https://tenant.accounts400.ondemand.com/oauth2/authorize", 12 | "token_endpoint": "https://tenant.accounts400.ondemand.com/oauth2/token", 13 | "end_session_endpoint": "https://tenant.accounts400.ondemand.com/oauth2/logout", 14 | "jwks_uri": "https://tenant.accounts400.ondemand.com/oauth2/certs", 15 | "response_types_supported": ["code", "id_token", "token"], 16 | "grant_types_supported": ["password", "authorization_code", "refresh_token", "client_credentials"], 17 | "subject_types_supported": ["public"], 18 | "id_token_signing_alg_values_supported": ["RS256"], 19 | "scopes_supported": ["openid", "email"], 20 | "token_endpoint_auth_methods_supported": ["tls_client_auth_subject_dn", "client_secret_basic"], 21 | "code_challenge_methods_supported": ["plain", "S256"], 22 | "tls_client_certificate_bound_access_tokens": True 23 | } 24 | 25 | JWKS = { 26 | "keys": [{ 27 | "kty": "RSA", 28 | "e": "AQAB", 29 | "use": "sig", 30 | "kid": "kid-custom", 31 | "alg": "RS256", 32 | "value": "public key here", 33 | "n": "AMGmSCHT8kourWCKVwTQKKr7a_rs8AGiwVPmeycKq2Mja5P3YXMDMOO7Qb9" 34 | "-v5YNv0dkD7eu9v4AzilpcnnGASQbewNbaz2wJWMwIvjxG7VcHjqcf-oF9bfHv8nR1TTp52OwSKaKqunMtIrS1uJ" 35 | "-1opcRmlX1x8zgi2l-XxzSKrLABz0Fq2GJGZmD1PU_" 36 | "-W6FHzE7ocokfYSViJ1_mBGn5KJwUIC2vBO9jWquGlM9TkdPP5DpmONEO5yFu6aO6GeEF3k9hOEL0AS0GOm8KmywhDg" 37 | "-s5FGVNuwNG0O_nQn3VI9jigXuKuz5_e1becT2rw88fpizFG476TwB6BQCk8SWc " 38 | }, { 39 | "kty": "RSA", 40 | "e": "AQAB", 41 | "use": "sig", 42 | "kid": "another-kid", 43 | "alg": "RS256", 44 | "value": "public key here", 45 | "n": "AMGmSCHT8kourWCKVwTQKKr7a_rs8AGiwVPmeycKq2Mja5P3YXMDMOO7Qb9" 46 | "-v5YNv0dkD7eu9v4AzilpcnnGASQbewNbaz2wJWMwIvjxG7VcHjqcf-oF9bfHv8nR1TTp52OwSKaKqunMtIrS1uJ" 47 | "-1opcRmlX1x8zgi2l-XxzSKrLABz0Fq2GJGZmD1PU_" 48 | "-W6FHzE7ocokfYSViJ1_mBGn5KJwUIC2vBO9jWquGlM9TkdPP5DpmONEO5yFu6aO6GeEF3k9hOEL0AS0GOm8KmywhDg" 49 | "-s5FGVNuwNG0O_nQn3VI9jigXuKuz5_e1becT2rw88fpizFG476TwB6BQCk8SWc " 50 | }] 51 | } 52 | -------------------------------------------------------------------------------- /CONTRIBUTING_USING_GENAI.md: -------------------------------------------------------------------------------- 1 | # Guideline for AI-generated code contributions to SAP Open Source Software Projects 2 | 3 | As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our open-source projects there are certain requirements that need to be reflected and adhered to when making contributions. 4 | 5 | When using AI-generated code contributions in OSS Projects, their usage needs to align with Open-Source Software values and legal requirements. We have established these essential guidelines to help contributors navigate the complexities of using AI tools while maintaining compliance with open-source licenses and the broader [Open-Source Definition](https://opensource.org/osd). 6 | 7 | AI-generated code or content can be contributed to SAP Open Source Software projects if the following conditions are met: 8 | 9 | 1. **Compliance with AI Tool Terms and Conditions**: Contributors must ensure that the AI tool's terms and conditions do not impose any restrictions on the tool's output that conflict with the project's open-source license or intellectual property policies. This includes ensuring that the AI-generated content adheres to the [Open-Source Definition](https://opensource.org/osd). 10 | 2. **Filtering Similar Suggestions**: Contributors must use features provided by AI tools to suppress responses that are similar to third-party materials or flag similarities. We only accept contributions from AI tools with such filtering options. If the AI tool flags any similarities, contributors must review and ensure compliance with the licensing terms of such materials before including them in the project. 11 | 3. **Management of Third-Party Materials**: If the AI tool's output includes pre-existing copyrighted materials, including open-source code authored or owned by third parties, contributors must verify that they have the necessary permissions from the original owners. This typically involves ensuring that there is an open-source license or public domain declaration that is compatible with the project's licensing policies. Contributors must also provide appropriate notice and attribution for these third-party materials, along with relevant information about the applicable license terms. 12 | 4. **Employer Policies Compliance**: If AI-generated content is contributed in the context of employment, contributors must also adhere to their employer’s policies. This ensures that all contributions are made with proper authorization and respect for relevant corporate guidelines. 13 | -------------------------------------------------------------------------------- /tests/ias/test_xssec_ias.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,invalid-name,missing-docstring,too-many-public-methods 2 | import unittest 3 | from os import environ 4 | from parameterized import parameterized_class 5 | from sap import xssec 6 | from sap.xssec import jwt_validation_facade, security_context_ias 7 | from tests.ias import ias_configs 8 | from tests.ias.ias_configs import SERVICE_CREDENTIALS 9 | from tests.ias.ias_tokens import TOKEN_INVALID_ISSUER, VALID_TOKEN, TOKEN_INVALID_AUDIENCE, TOKEN_EXPIRED, PAYLOAD, \ 10 | HEADER 11 | from tests.keys import JWT_SIGNING_PUBLIC_KEY 12 | 13 | try: 14 | from importlib import reload 15 | from unittest.mock import MagicMock, patch 16 | except ImportError: 17 | reload = None 18 | from mock import MagicMock, patch 19 | 20 | VERIFICATION_KEY_PARAMS = { 21 | "issuer_url": PAYLOAD["iss"], 22 | "app_tid": PAYLOAD["app_tid"] or PAYLOAD["zone_uuid"], 23 | "azp": PAYLOAD["azp"], 24 | "client_id": SERVICE_CREDENTIALS["clientid"], 25 | "kid": HEADER["kid"] 26 | } 27 | 28 | 29 | class IASXSSECTest(unittest.TestCase): 30 | 31 | def setUp(self): 32 | reload(jwt_validation_facade) 33 | reload(security_context_ias) 34 | 35 | @patch('sap.xssec.security_context_ias.get_verification_key_ias', return_value=JWT_SIGNING_PUBLIC_KEY) 36 | def test_input_validation_valid_token(self, get_verification_key_ias_mock): 37 | xssec.create_security_context_ias(VALID_TOKEN, ias_configs.SERVICE_CREDENTIALS) 38 | get_verification_key_ias_mock.assert_called_with(**VERIFICATION_KEY_PARAMS) 39 | 40 | def test_input_validation_invalid_token(self): 41 | with self.assertRaises(ValueError) as ctx: 42 | xssec.create_security_context_ias("some-invalid-token", ias_configs.SERVICE_CREDENTIALS) 43 | self.assertEqual("Failed to decode provided token", str(ctx.exception)) 44 | 45 | def test_input_validation_invalid_issuer(self): 46 | with self.assertRaises(ValueError) as ctx: 47 | xssec.create_security_context_ias(TOKEN_INVALID_ISSUER, ias_configs.SERVICE_CREDENTIALS) 48 | self.assertEqual("Token's issuer is not found in domain list " + SERVICE_CREDENTIALS["domain"], 49 | str(ctx.exception)) 50 | 51 | def test_input_validation_token_expired(self): 52 | with self.assertRaises(ValueError) as ctx: 53 | xssec.create_security_context_ias(TOKEN_EXPIRED, ias_configs.SERVICE_CREDENTIALS) 54 | self.assertEqual("Token has expired", str(ctx.exception)) 55 | 56 | def test_input_validation_invalid_audience(self): 57 | with self.assertRaises(RuntimeError) as ctx: 58 | xssec.create_security_context_ias(TOKEN_INVALID_AUDIENCE, ias_configs.SERVICE_CREDENTIALS) 59 | self.assertEqual("Audience Validation Failed", str(ctx.exception)) 60 | -------------------------------------------------------------------------------- /tests/http_responses.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tests.keys import JWT_SIGNING_PUBLIC_KEY 4 | 5 | KEY_ID_0 = JWT_SIGNING_PUBLIC_KEY 6 | 7 | KEY_ID_1 = "ANOTHER-KEY" 8 | 9 | HTTP_SUCCESS = json.loads(r'''{ 10 | "keys": [ 11 | { 12 | "kty": "RSA", 13 | "e": "AQAB", 14 | "use": "sig", 15 | "kid": "key-id-0", 16 | "alg": "RS256", 17 | "value": "%s", 18 | "n": "AJjTNzl32UtFLvHmGVwoBlhYFVkF-jB52nWJN8x2eTyD3g2NwKWkhqTBIlcJ9XE-ilFRzCx3Js9YLDcu 19 | -KQp5gmttluydwaGbpc0dAN-2sjFa0R4d5334MkpPLufNZdNm723KWm93txKLUjeS4sRk9VVmbw22pV3-p-ZKuOfTVi 20 | -mc5BLNtDKzhJOXC3Z7IoE0FB0iiEOU6ZXcg5CTJts8DpawdkffOPkHZQxZqFR-2Gro8a9oNGferu1vSJopOsE4hXPFu3lF34Txp 21 | -63lS6tf-aNjc9CcdHoxRw8Exp3LPpNUQUug26UzjK_bZCRHN2bF9xbeDragpEVyOYVJmvh8" 22 | }, 23 | { 24 | "kty": "RSA", 25 | "e": "AQAB", 26 | "use": "sig", 27 | "kid": "key-id-1", 28 | "alg": "RS256", 29 | "value": "%s", 30 | "n": "AJjTNzl32UtFLvHmGVwoBlhYFVkF-jB52nWJN8x2eTyD3g2NwKWkhqTBIlcJ9XE-ilFRzCx3Js9YLDcu 31 | -KQp5gmttluydwaGbpc0dAN-2sjFa0R4d5334MkpPLufNZdNm723KWm93txKLUjeS4sRk9VVmbw22pV3-p-ZKuOfTVi 32 | -mc5BLNtDKzhJOXC3Z7IoE0FB0iiEOU6ZXcg5CTJts8DpawdkffOPkHZQxZqFR-2Gro8a9oNGferu1vSJopOsE4hXPFu3lF34Txp 33 | -63lS6tf-aNjc9CcdHoxRw8Exp3LPpNUQUug26UzjK_bZCRHN2bF9xbeDragpEVyOYVJmvh8" 34 | } 35 | ] 36 | }''' % (KEY_ID_0, KEY_ID_1), strict=False) 37 | 38 | 39 | HTTP_SUCCESS_DUMMY = json.loads(r'''{ 40 | "keys": [ 41 | { 42 | "kty": "RSA", 43 | "e": "AQAB", 44 | "use": "sig", 45 | "kid": "key-id-0", 46 | "alg": "RS256", 47 | "value": "dummy-key", 48 | "n": "AJjTNzl32UtFLvHmGVwoBlhYFVkF-jB52nWJN8x2eTyD3g2NwKWkhqTBIlcJ9XE-ilFRzCx3Js9YLDcu 49 | -KQp5gmttluydwaGbpc0dAN-2sjFa0R4d5334MkpPLufNZdNm723KWm93txKLUjeS4sRk9VVmbw22pV3-p-ZKuOfTVi 50 | -mc5BLNtDKzhJOXC3Z7IoE0FB0iiEOU6ZXcg5CTJts8DpawdkffOPkHZQxZqFR-2Gro8a9oNGferu1vSJopOsE4hXPFu3lF34Txp 51 | -63lS6tf-aNjc9CcdHoxRw8Exp3LPpNUQUug26UzjK_bZCRHN2bF9xbeDragpEVyOYVJmvh8" 52 | }, 53 | { 54 | "kty": "RSA", 55 | "e": "AQAB", 56 | "use": "sig", 57 | "kid": "key-id-1", 58 | "alg": "RS256", 59 | "value": "dummy-key", 60 | "n": "AJjTNzl32UtFLvHmGVwoBlhYFVkF-jB52nWJN8x2eTyD3g2NwKWkhqTBIlcJ9XE-ilFRzCx3Js9YLDcu 61 | -KQp5gmttluydwaGbpc0dAN-2sjFa0R4d5334MkpPLufNZdNm723KWm93txKLUjeS4sRk9VVmbw22pV3-p-ZKuOfTVi 62 | -mc5BLNtDKzhJOXC3Z7IoE0FB0iiEOU6ZXcg5CTJts8DpawdkffOPkHZQxZqFR-2Gro8a9oNGferu1vSJopOsE4hXPFu3lF34Txp 63 | -63lS6tf-aNjc9CcdHoxRw8Exp3LPpNUQUug26UzjK_bZCRHN2bF9xbeDragpEVyOYVJmvh8" 64 | } 65 | ] 66 | }''', strict=False) 67 | 68 | 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to an SAP Open Source Project 2 | 3 | ## General Remarks 4 | 5 | You are welcome to contribute content (code, documentation etc.) to this open source project. 6 | 7 | There are some important things to know: 8 | 9 | 1. You must **comply to the license of this project**, **accept the Developer Certificate of Origin** (see below) before being able to contribute. The acknowledgement to the DCO will usually be requested from you as part of your first pull request to this project. 10 | 2. Please **adhere to our [Code of Conduct](CODE_OF_CONDUCT.md)**. 11 | 3. If you plan to use **generative AI for your contribution**, please see our guideline below. 12 | 4. **Not all proposed contributions can be accepted**. Some features may fit another project better or doesn't fit the general direction of this project. Of course, this doesn't apply to most bug fixes, but a major feature implementation for instance needs to be discussed with one of the maintainers first. Possibly, one who touched the related code or module recently. The more effort you invest, the better you should clarify in advance whether the contribution will match the project's direction. The best way would be to just open an issue to discuss the feature you plan to implement (make it clear that you intend to contribute). We will then forward the proposal to the respective code owner. This avoids disappointment. 13 | 14 | ## Developer Certificate of Origin (DCO) 15 | 16 | Contributors will be asked to accept a DCO before they submit the first pull request to this projects, this happens in an automated fashion during the submission process. SAP uses [the standard DCO text of the Linux Foundation](https://developercertificate.org/). 17 | 18 | ## Contributing with AI-generated code 19 | 20 | As artificial intelligence evolves, AI-generated code is becoming valuable for many software projects, including open-source initiatives. While we recognize the potential benefits of incorporating AI-generated content into our open-source projects there a certain requirements that need to be reflected and adhered to when making contributions. 21 | 22 | Please see our [guideline for AI-generated code contributions to SAP Open Source Software Projects](CONTRIBUTING_USING_GENAI.md) for these requirements. 23 | 24 | ## How to Contribute 25 | 26 | 1. Make sure the change is welcome (see [General Remarks](#general-remarks)). 27 | 2. Create a branch by forking the repository and apply your change. 28 | 3. Commit and push your change on that branch. 29 | 4. Create a pull request in the repository using this branch. 30 | 5. Follow the link posted by the CLA assistant to your pull request and accept it, as described above. 31 | 6. Wait for our code review and approval, possibly enhancing your change on request. 32 | - Note that the maintainers have many duties. So, depending on the required effort for reviewing, testing, and clarification, this may take a while. 33 | 7. Once the change has been approved and merged, we will inform you in a comment. 34 | 8. Celebrate! 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Change Log 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | The format is based on [Keep a Changelog](http://keepachangelog.com/). 8 | 9 | ## 4.2.2 10 | ### Changed 11 | - Bumped transitive dependency `h11` to version `0.16` to address `CVE-2025-43859`. 12 | ### Removed 13 | - Dropped support for Python 3.7 (EOL as of June 2023) 14 | ### Added 15 | - Added support for Python 3.12 16 | 17 | ## 4.2.1 18 | ### Added 19 | - Allow overriding HTTP timeout using environment variable `XSSEC_HTTP_TIMEOUT_IN_SECONDS` 20 | 21 | ## 4.2.0 22 | ### Changed 23 | - Allow `issuer` field without `https://` prefix in IAS token 24 | 25 | ## 4.1.0 26 | ### Changed 27 | - Removed JKU validation for XSUAA tokens and replaced it with composing JKU using UAA Domain. 28 | - Added extra HTTP headers for improved IAS verification key retrieval. 29 | - Implemented more strict issuer validation for IAS tokens. 30 | 31 | ## 4.0.1 32 | ### Fixed 33 | - Bug: fix `aud` validation for IAS tokens 34 | 35 | ## 4.0.0 36 | ### Removed 37 | - Removed suport for sap_py_jwt 38 | 39 | ## 3.3.0 40 | ### Added 41 | 42 | - Added IAS support 43 | 44 | ## 3.2.0 45 | ### Added 46 | 47 | - Support `async` user token requests 48 | 49 | ## 3.1.0 50 | ### Added 51 | 52 | - Support for tokenexchanges with X.509 certificates managed by XSUAA 53 | - Support for tokenexchanges with manually managed X.509 certificates 54 | - Support for configuration objects that does not provide a clientsecret (but a certificate) 55 | 56 | ## 3.0.0 57 | ### Removed 58 | - Removed support for python 2 59 | 60 | ### Changed 61 | - Replaced *requests* library with *httpx* for better async support 62 | 63 | ## 2.1.0 64 | 65 | ### Added 66 | - Support for token audience 67 | 68 | ## 2.0.12 69 | 70 | ### Fixed 71 | - Bug: wrong variable name used for debug logging during token validation 72 | 73 | ## 2.0.11 74 | 75 | ### Added 76 | - Support for zone_id and zid. 77 | 78 | ### Fixed 79 | - Improved jku validation 80 | 81 | ## 2.0.10 82 | 83 | ### Changed 84 | - Dependency update for six 85 | 86 | ## 2.0.9 87 | 88 | ### Changed 89 | - Fix for SAP_JWT_TRUST_ACL; fails after first non-matching entry. 90 | 91 | ## 2.0.8 92 | 93 | ### Changed 94 | - Fix for broker plan; adapt fix from node/xssec version 2.1.14 95 | 96 | ## 2.0.7 97 | 98 | ### Changed 99 | - Use sap_py_jwt as default library for decoding 100 | 101 | ### Added 102 | - Implement resilience: add retry for key retrieval 103 | 104 | ## 2.0.6 105 | 106 | ### Fixed 107 | - Added cryptography as dependency for pyjwt 108 | 109 | ## 2.0.5 110 | 111 | ### Fixed 112 | - XSA fix: Do not require uaadomain in VCAP_SERVICES but use local verificationkey 113 | 114 | ## 2.0.4 115 | 116 | ### Fixed 117 | - Dependecy for automatic pip install repaired 118 | 119 | ## 2.0.2 120 | 121 | ### Added 122 | - Optional signature validation with pyjwt or sap-py-jwt 123 | 124 | ## 2.0.1 125 | 126 | ### Added 127 | - Load key from token_keys and use KeyCache 128 | 129 | ## 2.0.0 130 | 131 | ### Added 132 | - Initial version. 133 | -------------------------------------------------------------------------------- /sap/xssec/key_cache_v2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Thread safe cache for verification keys. 3 | For XSUAA, each verification key is identified by its jku and kid. 4 | For IAS, each verification key is identified by issuer_url, zone_id and kid. 5 | There are a maximum of KEYCACHE_DEFAULT_CACHE_SIZE keys in the cache and 6 | keys are invalid if KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES have passed since the key 7 | has been in inserted in the cache. 8 | 9 | Improvement: use dedicated lock for each cache entry key 10 | """ 11 | from collections import defaultdict 12 | from functools import _make_key # noqa 13 | from threading import Lock 14 | from typing import List, Dict, Any 15 | 16 | import httpx 17 | from cachetools import cached, TTLCache 18 | 19 | from sap.xssec.constants import HTTP_TIMEOUT_IN_SECONDS, KEYCACHE_DEFAULT_CACHE_SIZE, \ 20 | KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES 21 | from sap.xssec.key_tools import jwk_to_pem 22 | 23 | 24 | def thread_safe_by_args(func): 25 | lock_dict = defaultdict(Lock) 26 | 27 | def _thread_safe_func(*args, **kwargs): 28 | key = _make_key(args, kwargs, typed=False) 29 | with lock_dict[key]: 30 | return func(*args, **kwargs) 31 | 32 | return _thread_safe_func 33 | 34 | 35 | def _fetch_verification_key_url_ias(issuer_url: str) -> str: 36 | """ 37 | get the verification key url from issuer's well-known endpoint 38 | """ 39 | resp = httpx.get(issuer_url + '/.well-known/openid-configuration', headers={'Accept': 'application/json'}, 40 | timeout=HTTP_TIMEOUT_IN_SECONDS) 41 | resp.raise_for_status() 42 | return resp.json()["jwks_uri"] 43 | 44 | 45 | def _download_verification_key_ias(verification_key_url: str, app_tid: str, azp: str, 46 | client_id: str) -> List[Dict[str, Any]]: 47 | """ 48 | get all the keys from verification key url 49 | """ 50 | headers = { 51 | 'x-app_tid': app_tid, 52 | 'x-azp': azp, 53 | 'x-client_id': client_id, 54 | 'Accept': 'application/json', 55 | } 56 | headers = {k: v for k, v in headers.items() if v is not None} 57 | resp = httpx.get(verification_key_url, headers=headers, timeout=HTTP_TIMEOUT_IN_SECONDS) 58 | resp.raise_for_status() 59 | return resp.json()["keys"] 60 | 61 | 62 | key_cache = TTLCache( 63 | maxsize=KEYCACHE_DEFAULT_CACHE_SIZE, ttl=KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES * 60) 64 | 65 | 66 | @thread_safe_by_args 67 | @cached(cache=key_cache) 68 | def get_verification_key_ias(issuer_url: str, app_tid: str, azp: str, client_id: str, kid: str) -> str: 69 | """ 70 | get verification key for ias 71 | """ 72 | verification_key_url: str = _fetch_verification_key_url_ias(issuer_url) 73 | verification_key_list: List[Dict[str, Any]] = _download_verification_key_ias(verification_key_url, app_tid, azp, 74 | client_id) 75 | found = list(filter(lambda k: k["kid"] == kid, verification_key_list)) 76 | if len(found) == 0: 77 | raise ValueError("Could not find key with kid {}".format(kid)) 78 | return jwk_to_pem(found[0]) 79 | 80 | 81 | @thread_safe_by_args 82 | @cached(cache=key_cache) 83 | def get_verification_key_xsuaa(jku: str, kid: str) -> str: 84 | """ 85 | get verification key for xsuaa 86 | """ 87 | # TODO 88 | raise NotImplementedError() 89 | -------------------------------------------------------------------------------- /sap/xssec/key_cache.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from httpx import HTTPError, HTTPStatusError, TimeoutException 4 | 5 | import httpx 6 | from collections import OrderedDict 7 | from threading import Lock 8 | 9 | from sap.xssec.constants import * 10 | 11 | lock = Lock() 12 | 13 | 14 | class CacheEntry(object): 15 | def __init__(self, key, insert_timestamp): 16 | self.key = key 17 | self.insert_timestamp = insert_timestamp 18 | 19 | def is_valid(self): 20 | return self.insert_timestamp + KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES * 60 >= time.time() 21 | 22 | 23 | class KeyCache(object): 24 | """ 25 | Thread safe cache for verification keys. Each verification key is identified by its jku and pid. 26 | There are a maximum of KEYCACHE_DEFAULT_CACHE_SIZE keys in the cache and 27 | keys are invalid if KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES have passed since the key 28 | has been in inserted in the cache. 29 | """ 30 | 31 | def __init__(self): 32 | self._cache = OrderedDict() 33 | self._logger = logging.getLogger(__name__) 34 | 35 | def load_key(self, jku, kid): 36 | """ 37 | Either returns key from cache or retrieves it from the UAA. 38 | :param jku: jku of token 39 | :param kid: kid of token 40 | :return: verification key 41 | """ 42 | with lock: 43 | self._logger.debug("Loading verification key for 'jku'={} and kid={}".format(jku, kid)) 44 | cache_key = self._create_cache_key(jku, kid) 45 | 46 | if cache_key in self._cache: 47 | if self._cache[cache_key].is_valid(): 48 | self._logger.debug("Using cached verification key") 49 | return self._cache[cache_key].key 50 | else: 51 | self._logger.debug("Verification key expired. Retrieving key from uua.") 52 | self._cache.pop(cache_key) 53 | 54 | self._logger.debug("Key not cached. Retrieving key from uua.") 55 | 56 | key = self._retrieve_key(jku, kid) 57 | self._cache[cache_key] = CacheEntry(key, time.time()) 58 | 59 | # remove oldest key if cache is full 60 | if len(self._cache) > KEYCACHE_DEFAULT_CACHE_SIZE: 61 | self._cache.popitem(last=False) 62 | 63 | return key 64 | 65 | def _retrieve_key(self, jku, kid): 66 | try: 67 | r = self._request_key_with_retry(jku) 68 | r_json = r.json() 69 | 70 | for key in r_json.get('keys', {}): 71 | if key.get('kid', "") == kid: 72 | return key['value'] 73 | 74 | raise ValueError("Could not find key with kid {}".format(kid)) 75 | except HTTPError as e: 76 | self._logger.error("Error while trying to get key from uaa. {}".format(e)) 77 | raise 78 | 79 | def _request_key_with_retry(self, jku): 80 | i = 0 81 | while True: 82 | try: 83 | r = httpx.get(jku, timeout=HTTP_TIMEOUT_IN_SECONDS) 84 | r.raise_for_status() 85 | return r 86 | except (HTTPStatusError, TimeoutException) as e: 87 | if i < HTTP_RETRY_NUMBER_RETRIES and (isinstance(e, TimeoutException) or 88 | e.response.status_code in HTTP_RETRY_ON_ERROR_CODE): 89 | i = i + 1 90 | self._logger.warning("Warning: Error while trying to get key from uaa. {}. Start retry attempt {}". 91 | format(e, str(i))) 92 | time.sleep(2**(i-1) * HTTP_RETRY_BACKOFF_FACTOR) 93 | else: 94 | raise 95 | 96 | @staticmethod 97 | def _create_cache_key(jku, kid): 98 | return jku + kid 99 | -------------------------------------------------------------------------------- /sap/xssec/jwt_audience_validator.py: -------------------------------------------------------------------------------- 1 | class JwtAudienceValidator(object): 2 | ''' 3 | Validates if the jwt access token is intended for the OAuth2 client of this 4 | application. The aud (audience) claim identifies the recipients the JWT is 5 | issued for. 6 | 7 | Validates whether there is one audience that matches one of the configured 8 | OAuth2 client ids. 9 | ''' 10 | 11 | DOT = "." 12 | 13 | def __init__(self, clientid): 14 | self._clientid = clientid 15 | self._trusted_clientids = set() 16 | self.trusted_clientids = clientid 17 | self._is_foreign_mode = False 18 | 19 | 20 | @property 21 | def trusted_clientids(self): 22 | return self._trusted_clientids 23 | 24 | @trusted_clientids.setter 25 | def trusted_clientids(self, clientid): 26 | if clientid: 27 | self._trusted_clientids.add(clientid) 28 | 29 | @property 30 | def is_foreign_mode(self): 31 | return False 32 | 33 | @is_foreign_mode.setter 34 | def is_foreign_mode(self, foreignmode): 35 | self._is_foreign_mode = foreignmode 36 | 37 | @property 38 | def clientid(self): 39 | return self._clientid 40 | 41 | @clientid.setter 42 | def clientid(self, clientId): 43 | self._clientid = clientId 44 | 45 | def configure_trusted_clientId(self, client_id): 46 | if client_id: 47 | self.trusted_clientids.add(client_id) 48 | 49 | def validate_token(self, clientId_from_token=None, audiences_from_token=[], scopes_from_token=[]): 50 | self.is_foreign_mode = False 51 | allowed_audiences = self.extract_audiences_from_token(audiences_from_token, scopes_from_token, clientId_from_token) 52 | if (self.validate_same_clientId(clientId_from_token) == True or 53 | self.validate_audience_of_xsuaabrokerclone(allowed_audiences) == True or 54 | self.validate_default(allowed_audiences)==True): 55 | return True 56 | else: 57 | return False 58 | 59 | 60 | def extract_audiences_from_token(self, audiences_from_token=[], scopes_from_token=[], clientid_from_token=None): 61 | ''' 62 | Extracts Audience From Token 63 | ''' 64 | audiences = [] 65 | token_audiences = audiences_from_token 66 | for audience in token_audiences: 67 | if audience.find(self.DOT) > -1: 68 | # CF UAA derives the audiences from the scopes. 69 | # In case the scopes contains namespaces, these needs to be removed. 70 | audience = audience[0:audience.find(self.DOT)].strip() 71 | if audience and (audience not in audiences): 72 | audiences.append(audience) 73 | else: 74 | audiences.append(audience) 75 | 76 | if len(audiences) == 0: 77 | 78 | for scope in scopes_from_token: 79 | 80 | if scope.find(self.DOT) > -1: 81 | audience = scope[0 :scope.find(self.DOT)].strip() 82 | if audience : 83 | if (audience not in audiences): 84 | audiences.append(audience) 85 | 86 | if (clientid_from_token and (clientid_from_token not in audiences)): 87 | audiences.append(clientid_from_token) 88 | 89 | return audiences 90 | 91 | def validate_same_clientId(self, clientid_from_token): 92 | if clientid_from_token == self.clientid: 93 | return True 94 | else: 95 | return False 96 | 97 | def validate_audience_of_xsuaabrokerclone(self, allowed_audiences): 98 | for configured_clientid in self.trusted_clientids: 99 | if ("!b") in configured_clientid: 100 | # isBrokerClientId 101 | for audience in allowed_audiences: 102 | if (audience.endswith("|" + configured_clientid)): 103 | return True 104 | self.is_foreign_mode=True 105 | return False 106 | 107 | def validate_default(self, allowedAudiences): 108 | for configuredClientId in self.trusted_clientids: 109 | if configuredClientId in allowedAudiences: 110 | return True 111 | 112 | return False 113 | -------------------------------------------------------------------------------- /sap/xssec/security_context_ias.py: -------------------------------------------------------------------------------- 1 | """ Security Context class for IAS support""" 2 | import logging 3 | import re 4 | from typing import List, Dict 5 | from urllib3.util import Url, parse_url # type: ignore 6 | from sap.xssec.jwt_audience_validator import JwtAudienceValidator 7 | from sap.xssec.jwt_validation_facade import JwtValidationFacade, DecodeError 8 | from sap.xssec.key_cache import KeyCache 9 | from sap.xssec.key_cache_v2 import get_verification_key_ias 10 | 11 | 12 | class SecurityContextIAS(object): 13 | """ SecurityContextIAS class """ 14 | 15 | verificationKeyCache = KeyCache() 16 | 17 | def __init__(self, token: str, service_credentials: Dict[str, str]): 18 | self.token = token 19 | self.service_credentials = service_credentials 20 | self.logger = logging.getLogger(__name__) 21 | self.jwt_validator = JwtValidationFacade() 22 | self.audience_validator = JwtAudienceValidator(self.service_credentials["clientid"]) 23 | try: 24 | self.token_payload = self.jwt_validator.decode(token, False) 25 | self.token_header = self.jwt_validator.get_unverified_header(token) 26 | self.validate_issuer().validate_timestamp().validate_audience().validate_signature() 27 | except DecodeError: 28 | raise ValueError("Failed to decode provided token") 29 | 30 | def get_issuer(self): 31 | issuer = self.token_payload.get("ias_iss") or self.token_payload["iss"] 32 | return issuer if issuer.startswith("https://") else f"https://{issuer}" 33 | 34 | def validate_issuer(self): 35 | """ 36 | check `ias_iss` or `iss` in jwt token 37 | """ 38 | issuer_url: Url = parse_url(self.get_issuer()) 39 | if issuer_url.scheme != "https": 40 | raise ValueError("Token's issuer has wrong protocol ({})".format(issuer_url.scheme)) 41 | 42 | if issuer_url.query is not None: 43 | raise ValueError("Token's issuer has unallowed query value ({})".format(issuer_url.query)) 44 | 45 | if issuer_url.fragment is not None: 46 | raise ValueError("Token's issuer has unallowed hash value ({})".format(issuer_url.fragment)) 47 | 48 | domains: List[str] = self.service_credentials.get("domains") or ( 49 | [self.service_credentials["domain"]] if "domain" in self.service_credentials else []) 50 | 51 | def validate_issuer_subdomain(parent_domain) -> bool: 52 | pattern = r'^https://[a-zA-Z0-9-]{{1,63}}\.{parent_domain}$'.format(parent_domain=re.escape(parent_domain)) 53 | return bool(re.match(pattern, self.get_issuer())) 54 | 55 | if not any(map(validate_issuer_subdomain, domains)): 56 | raise ValueError("Token's issuer is not found in domain list {}".format(", ".join(domains))) 57 | 58 | return self 59 | 60 | def validate_timestamp(self): 61 | """ 62 | check `exp` in jwt token 63 | """ 64 | if self.jwt_validator.has_token_expired(self.token): 65 | raise ValueError("Token has expired") 66 | return self 67 | 68 | def validate_audience(self): 69 | """ 70 | check `aud` in jwt token 71 | """ 72 | 73 | # Make sure `aud` is a list 74 | aud = [self.token_payload["aud"]] if isinstance(self.token_payload["aud"], str) else self.token_payload["aud"] 75 | 76 | validation_result = self.audience_validator.validate_token(audiences_from_token=aud) 77 | if validation_result is False: 78 | raise RuntimeError('Audience Validation Failed') 79 | return self 80 | 81 | def validate_signature(self): 82 | """ 83 | check signature in jwt token 84 | """ 85 | verification_key: str = get_verification_key_ias( 86 | issuer_url=self.get_issuer(), 87 | app_tid=self.token_payload.get("app_tid") or self.token_payload.get("zone_uuid"), 88 | azp=self.token_payload.get("azp"), 89 | client_id=self.service_credentials["clientid"], 90 | kid=self.token_header["kid"], 91 | ) 92 | 93 | result_code = self.jwt_validator.loadPEM(verification_key) 94 | if result_code != 0: 95 | raise RuntimeError('Invalid verification key, result code {0}'.format(result_code)) 96 | 97 | self.jwt_validator.checkToken(self.token) 98 | error_description = self.jwt_validator.getErrorDescription() 99 | if error_description != '': 100 | raise RuntimeError( 101 | 'Error in validation of access token: {0}, result code {1}'.format( 102 | error_description, self.jwt_validator.getErrorRC())) 103 | 104 | return self 105 | -------------------------------------------------------------------------------- /tests/test_key_cache_v2.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from time import sleep 3 | from typing import List, Callable 4 | 5 | import pytest 6 | from httpx import Response, HTTPStatusError, Request 7 | 8 | from sap.xssec.key_tools import jwk_to_pem 9 | from tests.ias.ias_configs import JWKS, WELL_KNOWN, SERVICE_CREDENTIALS 10 | from tests.ias.ias_tokens import PAYLOAD, HEADER, merge 11 | 12 | VERIFICATION_KEY_PARAMS = { 13 | "issuer_url": PAYLOAD["iss"], 14 | "app_tid": PAYLOAD["app_tid"] or PAYLOAD["zone_uuid"], 15 | "azp": PAYLOAD["azp"], 16 | "client_id": SERVICE_CREDENTIALS["clientid"], 17 | "kid": HEADER["kid"] 18 | } 19 | 20 | 21 | def test_thread_safe_decorator(): 22 | sum = 0 23 | 24 | def add_to_sum(x: int): 25 | nonlocal sum 26 | local_sum = sum 27 | sleep(0.1) 28 | sum = local_sum + x 29 | 30 | def run_func_in_threads(func: Callable[[int], None], func_args: List[int]): 31 | threads = [] 32 | for arg in func_args: 33 | t = threading.Thread(target=func, args=[arg]) 34 | threads.append(t) 35 | t.start() 36 | for t in threads: 37 | t.join() 38 | 39 | # not thread-safe without decorator 40 | sum = 0 41 | run_func_in_threads(add_to_sum, [1] * 10) 42 | assert 10 != sum 43 | 44 | # thread-safe when args are same 45 | sum = 0 46 | from sap.xssec.key_cache_v2 import thread_safe_by_args 47 | run_func_in_threads(thread_safe_by_args(add_to_sum), [1] * 10) 48 | assert 10 == sum 49 | 50 | # not thread-safe when args are different 51 | sum = 0 52 | run_func_in_threads(thread_safe_by_args(add_to_sum), list(range(1, 11))) 53 | assert 55 != sum 54 | 55 | 56 | @pytest.fixture 57 | def well_known_endpoint_mock(respx_mock): 58 | return respx_mock.get(PAYLOAD["iss"] + '/.well-known/openid-configuration').mock( 59 | return_value=Response(200, json=WELL_KNOWN)) 60 | 61 | 62 | def jwk_endpoint_response(request: Request): 63 | if all(k in request.headers for k in ("x-app_tid", "x-azp", "x-client_id")): 64 | return Response(200, json=JWKS) 65 | else: 66 | return Response(404) 67 | 68 | 69 | @pytest.fixture 70 | def jwk_endpoint_mock(respx_mock): 71 | return respx_mock.get(WELL_KNOWN["jwks_uri"]).mock(side_effect=jwk_endpoint_response) 72 | 73 | 74 | def test_get_verification_key_ias_should_return_key(well_known_endpoint_mock, jwk_endpoint_mock): 75 | from sap.xssec.key_cache_v2 import get_verification_key_ias, key_cache 76 | key_cache.clear() 77 | pem_key = get_verification_key_ias(**VERIFICATION_KEY_PARAMS) 78 | assert well_known_endpoint_mock.called 79 | assert jwk_endpoint_mock.called 80 | jwk = next(filter(lambda k: k["kid"] == HEADER["kid"], JWKS["keys"])) 81 | assert jwk_to_pem(jwk) == pem_key 82 | 83 | 84 | def test_get_verification_key_ias_should_cache_key(well_known_endpoint_mock, jwk_endpoint_mock): 85 | from sap.xssec.key_cache_v2 import get_verification_key_ias, key_cache 86 | key_cache.clear() 87 | for _ in range(0, 10): 88 | get_verification_key_ias(**VERIFICATION_KEY_PARAMS) 89 | assert 1 == well_known_endpoint_mock.call_count == jwk_endpoint_mock.call_count 90 | 91 | for _ in range(0, 10): 92 | get_verification_key_ias(**merge(VERIFICATION_KEY_PARAMS, {"app_tid": "another-app-tid"})) 93 | assert 2 == well_known_endpoint_mock.call_count == jwk_endpoint_mock.call_count 94 | 95 | for _ in range(0, 10): 96 | get_verification_key_ias(**merge(VERIFICATION_KEY_PARAMS, {"azp": "another-azp"})) 97 | assert 3 == well_known_endpoint_mock.call_count == jwk_endpoint_mock.call_count 98 | 99 | for _ in range(0, 10): 100 | get_verification_key_ias(**merge(VERIFICATION_KEY_PARAMS, {"client_id": "another-client-id"})) 101 | assert 4 == well_known_endpoint_mock.call_count == jwk_endpoint_mock.call_count 102 | 103 | for _ in range(0, 10): 104 | get_verification_key_ias(**merge(VERIFICATION_KEY_PARAMS, {"kid": "another-kid"})) 105 | assert 5 == well_known_endpoint_mock.call_count == jwk_endpoint_mock.call_count 106 | 107 | 108 | def test_get_verification_key_ias_should_throw_error_for_missing_key(well_known_endpoint_mock, jwk_endpoint_mock): 109 | from sap.xssec.key_cache_v2 import get_verification_key_ias, key_cache 110 | key_cache.clear() 111 | for _ in range(0, 10): 112 | with pytest.raises(ValueError): 113 | get_verification_key_ias(**merge(VERIFICATION_KEY_PARAMS, {"kid": "non-existing-kid"})) 114 | assert 10 == well_known_endpoint_mock.call_count == jwk_endpoint_mock.call_count 115 | 116 | 117 | def test_get_verification_key_ias_should_raise_http_error(respx_mock): 118 | respx_mock.get(PAYLOAD["iss"] + '/.well-known/openid-configuration').mock( 119 | return_value=Response(500)) 120 | from sap.xssec.key_cache_v2 import get_verification_key_ias, key_cache 121 | key_cache.clear() 122 | with pytest.raises(HTTPStatusError): 123 | get_verification_key_ias(**VERIFICATION_KEY_PARAMS) 124 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # SAP Open Source Code of Conduct 2 | 3 | SAP adopts the [Contributor's Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) 4 | across our open source projects to ensure a welcoming and open culture for everyone involved. 5 | 6 | ## Our Pledge 7 | 8 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 9 | 10 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to a positive environment for our community include: 15 | 16 | * Demonstrating empathy and kindness toward other people 17 | * Being respectful of differing opinions, viewpoints, and experiences 18 | * Giving and gracefully accepting constructive feedback 19 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 20 | * Focusing on what is best not just for us as individuals, but for the overall community 21 | 22 | Examples of unacceptable behavior include: 23 | 24 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 25 | * Trolling, insulting or derogatory comments, and personal or political attacks 26 | * Public or private harassment 27 | * Publishing others' private information, such as a physical or email address, without their explicit permission 28 | * Other conduct which could reasonably be considered inappropriate in a professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [ospo@sap.com](mailto:ospo@sap.com) (SAP Open Source Program Office). All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 77 | 78 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 79 | 80 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 81 | 82 | [homepage]: https://www.contributor-covenant.org 83 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 84 | [Mozilla CoC]: https://github.com/mozilla/diversity 85 | [FAQ]: https://www.contributor-covenant.org/faq 86 | [translations]: https://www.contributor-covenant.org/translations 87 | -------------------------------------------------------------------------------- /tests/test_jwt_audience_validator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | try: 3 | from unittest.mock import MagicMock, patch 4 | except ImportError: 5 | from mock import MagicMock, patch 6 | 7 | 8 | from sap.xssec.jwt_audience_validator import JwtAudienceValidator 9 | 10 | 11 | class TestJwtAudienceValidator: 12 | 13 | XSUAA_BROKER_XSAPPNAME = "brokerplanmasterapp!b123" 14 | 15 | def test_constructor(self): 16 | self.jwt_audience_validator = JwtAudienceValidator(clientid="client") 17 | assert (self.jwt_audience_validator.trusted_clientids).__len__() == 1 18 | 19 | 20 | def test_tokenaudience_matches_clientid(self): 21 | clientid_from_token = "clientid1" 22 | self.jwt_audience_validator = JwtAudienceValidator(clientid_from_token) 23 | validation_result = self.jwt_audience_validator.validate_token(clientId_from_token=clientid_from_token) 24 | assert validation_result == True 25 | 26 | def test_tokenaudience_matches_appId(self): 27 | audiences_from_token=["appId!t1"] 28 | self.jwt_audience_validator = JwtAudienceValidator("sb-appId!t1") 29 | self.jwt_audience_validator.configure_trusted_clientId('appId!t1') 30 | validation_result = self.jwt_audience_validator.validate_token(audiences_from_token=audiences_from_token) 31 | assert validation_result == True 32 | 33 | def test_token_audience_matches_foreign_clientId(self): 34 | audiences_from_token = ["client", "foreignclient", "sb-test4!t1.data"] 35 | self.jwt_audience_validator = JwtAudienceValidator("any") 36 | self.jwt_audience_validator.configure_trusted_clientId('foreignclient') 37 | validation_result = self.jwt_audience_validator.validate_token(audiences_from_token=audiences_from_token) 38 | assert validation_result == True 39 | 40 | def test_clientid_matches_token_audience_without_dot(self): 41 | audiences_from_token = ["client", "sb-test4!t1.data.x"] 42 | self.jwt_audience_validator = JwtAudienceValidator("sb-test4!t1") 43 | validation_result = self.jwt_audience_validator.validate_token(audiences_from_token=audiences_from_token) 44 | assert validation_result == True 45 | 46 | def test_token_client_id_matches_trusted_clientid(self): 47 | self.jwt_audience_validator = JwtAudienceValidator("client") 48 | validation_result = self.jwt_audience_validator.validate_token(clientId_from_token="client") 49 | assert validation_result == True 50 | 51 | def test_broker_clientid_matches_clone_audience(self): 52 | audiences_from_token = ["sb-f7016e93-8665-4b73-9b46-f99d7808fe3c!b446|" + self.XSUAA_BROKER_XSAPPNAME] 53 | self.jwt_audience_validator = JwtAudienceValidator("sb-" + self.XSUAA_BROKER_XSAPPNAME) 54 | self.jwt_audience_validator.configure_trusted_clientId(self.XSUAA_BROKER_XSAPPNAME) 55 | validation_result = self.jwt_audience_validator.validate_token(audiences_from_token=audiences_from_token) 56 | assert validation_result == True 57 | 58 | def test_token_clientid_matches_trusted_broker_client_id(self): 59 | clientid_from_token = "sb-clone-app-id!b123|" + self.XSUAA_BROKER_XSAPPNAME 60 | self.jwt_audience_validator = JwtAudienceValidator(self.XSUAA_BROKER_XSAPPNAME) 61 | validation_result = self.jwt_audience_validator.validate_token(clientId_from_token= clientid_from_token) 62 | assert validation_result == True 63 | 64 | def test_token_clientid_does_not_match_trusted_broker_clientid(self): 65 | clientid_from_token = "sb-clone-app-id!b123|xxx" + self.XSUAA_BROKER_XSAPPNAME 66 | self.jwt_audience_validator = JwtAudienceValidator(self.XSUAA_BROKER_XSAPPNAME) 67 | validation_result = self.jwt_audience_validator.validate_token(clientId_from_token=clientid_from_token) 68 | assert validation_result == False 69 | 70 | def test_broker_clientid_does_not_match_clone_audience(self): 71 | audiencesfromToken = ["sb-f7016e93-8665-4b73-9b46-f99d7808fe3c!b446|ANOTHERAPP!b12"] 72 | self.jwt_audience_validator = JwtAudienceValidator("sb-" + self.XSUAA_BROKER_XSAPPNAME) 73 | self.jwt_audience_validator.configure_trusted_clientId(self.XSUAA_BROKER_XSAPPNAME) 74 | validation_result = self.jwt_audience_validator.validate_token(audiences_from_token=audiencesfromToken) 75 | assert validation_result == False 76 | 77 | def test_negative_when_no_token_audience_matches(self): 78 | audiences_from_token = ["client", "foreignclient", "sb-test4!t1.data"] 79 | self.jwt_audience_validator = JwtAudienceValidator("any") 80 | self.jwt_audience_validator.configure_trusted_clientId("anyOther") 81 | validation_result = self.jwt_audience_validator.validate_token(audiences_from_token=audiences_from_token) 82 | assert validation_result == False 83 | 84 | def test_should_filter_empty_audiences(self): 85 | audiences_from_token = [".", "test.", " .test2"] 86 | self.jwt_audience_validator = JwtAudienceValidator("any") 87 | validation_result = self.jwt_audience_validator.validate_token(audiences_from_token=audiences_from_token) 88 | assert validation_result == False 89 | 90 | def test_negative_fails_when_token_audiences_are_empty(self): 91 | self.jwt_audience_validator = JwtAudienceValidator("any") 92 | validation_result = self.jwt_audience_validator.validate_token() 93 | assert validation_result == False 94 | 95 | def test_extract_audiences_from_token_scopes(self): 96 | scopes = ["client.read", "test1!t1.read", "client.write", "xsappid.namespace.ns.write", "openid"] 97 | self.jwt_audience_validator = JwtAudienceValidator("client") 98 | audiences_result = self.jwt_audience_validator.extract_audiences_from_token(scopes_from_token=scopes) 99 | assert len(audiences_result) == 3 100 | assert 'client' in audiences_result 101 | assert 'xsappid' in audiences_result 102 | assert 'test1!t1' in audiences_result 103 | -------------------------------------------------------------------------------- /tests/test_req_token.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,invalid-name,missing-docstring 2 | import asyncio 3 | import unittest 4 | from unittest.mock import patch 5 | from os import environ, path, devnull 6 | import socket 7 | from time import sleep 8 | from subprocess import Popen 9 | from sap.xssec import jwt_validation_facade, constants 10 | from sap import xssec 11 | from tests import uaa_configs 12 | from tests import jwt_payloads 13 | from tests.jwt_tools import sign 14 | 15 | import requests 16 | 17 | from tests.keys import CLIENT_X509_CERTIFICATE, CLIENT_X509_KEY 18 | 19 | TEST_SERVER_POLL_ATTEMPTS = 10 20 | 21 | 22 | def get_free_tcp_port(): 23 | tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 24 | tcp.bind(('', 0)) 25 | _, port = tcp.getsockname() 26 | tcp.close() 27 | return port 28 | 29 | 30 | flask_env = environ.copy() 31 | 32 | flask_env['FLASK_APP'] = path.join(path.dirname( 33 | path.abspath(__file__)), 'utils', 'uaa_mock.py') 34 | flask_port = str(get_free_tcp_port()) 35 | flask_url = 'http://localhost:' + flask_port 36 | 37 | # Event loop for running async functions in tests 38 | loop = asyncio.get_event_loop() 39 | 40 | class ReqTokenForClientTest(unittest.TestCase): 41 | DEVNULL = None 42 | flask_process = None 43 | 44 | @classmethod 45 | def setUpClass(cls): 46 | """ Test class static setup """ 47 | 48 | cls.DEVNULL = open(devnull, 'w') 49 | cls.flask_process = Popen(['flask', 'run', '-p', flask_port, '-h', 'localhost'], 50 | env=flask_env, stdout=cls.DEVNULL, stderr=cls.DEVNULL) 51 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 52 | poll = 0 53 | 54 | while poll != TEST_SERVER_POLL_ATTEMPTS: 55 | try: 56 | sleep(1) 57 | poll += 1 58 | s.connect(('localhost', int(flask_port))) 59 | print('Test server is up!') # pylint: disable=superfluous-parens 60 | break 61 | except socket.error as e: 62 | if poll == TEST_SERVER_POLL_ATTEMPTS: 63 | print( 64 | 'Test server could not start!') # pylint: disable=superfluous-parens 65 | raise e 66 | s.close() 67 | 68 | jwt_validation_facade.ALGORITHMS = ['RS256', 'HS256'] 69 | 70 | @classmethod 71 | def tearDownClass(cls): 72 | if cls.flask_process: 73 | cls.flask_process.terminate() 74 | if cls.DEVNULL: 75 | cls.DEVNULL.close() 76 | 77 | def _request_token_for_client_error(self, sec_context, url, error_message_end): 78 | service_credentials = { 79 | 'clientid': 'clientid', 80 | 'clientsecret': 'clientsecret', 81 | 'url': url 82 | } 83 | with self.assertRaises(RuntimeError) as ctx: 84 | sec_context.request_token_for_client(service_credentials, None) 85 | self.assertTrue(str(ctx.exception).endswith(error_message_end)) 86 | 87 | def _setup_get_error(self, mock): 88 | mock.side_effect = requests.exceptions.SSLError 89 | 90 | def _req_client_service_credentials(self): 91 | service_credentials = { 92 | 'clientid': 'clientid', 93 | 'clientsecret': 'clientsecret', 94 | 'url': flask_url + '/correct' 95 | } 96 | return service_credentials 97 | 98 | def _req_client_sec_context(self): 99 | sec_context = xssec.create_security_context( 100 | sign(jwt_payloads.USER_TOKEN_JWT_BEARER_FOR_CLIENT), uaa_configs.VALID['uaa']) 101 | return sec_context 102 | 103 | @patch('httpx.get') 104 | def test_req_client_for_user_401_error(self, mock_get): 105 | self._setup_get_error(mock_get) 106 | 107 | sec_context = xssec.create_security_context( 108 | sign(jwt_payloads.USER_TOKEN_JWT_BEARER_FOR_CLIENT), uaa_configs.VALID['uaa']) 109 | sec_context = self._req_client_sec_context() 110 | 111 | expected_message = \ 112 | 'Authorization header invalid, requesting client does'\ 113 | ' not have grant_type={} or no scopes were granted.'.format(constants.GRANTTYPE_JWT_BEARER) 114 | 115 | self._request_token_for_client_error( 116 | sec_context, flask_url + '/401', expected_message) 117 | 118 | @patch('httpx.get') 119 | def test_req_client_for_user_500_error(self, mock_get): 120 | self._setup_get_error(mock_get) 121 | 122 | sec_context = self._req_client_sec_context() 123 | self._request_token_for_client_error( 124 | sec_context, flask_url + '/500', 'HTTP status code: 500') 125 | 126 | @patch('httpx.get') 127 | def test_req_client_for_user(self, mock_get): 128 | self._setup_get_error(mock_get) 129 | 130 | sec_context = self._req_client_sec_context() 131 | service_credentials = self._req_client_service_credentials() 132 | token = sec_context.request_token_for_client(service_credentials, None) 133 | self.assertEqual(token, 'access_token') 134 | 135 | @patch('httpx.get') 136 | def test_req_client_for_user_with_mtls(self, mock_get): 137 | self._setup_get_error(mock_get) 138 | 139 | sec_context = xssec.create_security_context( 140 | sign(jwt_payloads.USER_TOKEN_JWT_BEARER_FOR_CLIENT), uaa_configs.VALID['uaa']) 141 | service_credentials = { 142 | 'clientid': 'clientid', 143 | 'certificate': CLIENT_X509_CERTIFICATE, 144 | 'key': CLIENT_X509_KEY, 145 | 'certurl': flask_url + '/mtls' 146 | } 147 | token = sec_context.request_token_for_client(service_credentials, None) 148 | 149 | @patch('httpx.get') 150 | def test_req_client_for_user_async(self, mock_get): 151 | self._setup_get_error(mock_get) 152 | 153 | sec_context = self._req_client_sec_context() 154 | service_credentials = self._req_client_service_credentials() 155 | coro = sec_context.request_token_for_client_async(service_credentials) 156 | token = loop.run_until_complete(coro) 157 | self.assertEqual(token, 'access_token') 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![REUSE status](https://api.reuse.software/badge/github.com/SAP/cloud-pysec)](https://api.reuse.software/info/github.com/SAP/cloud-pysec) 2 | 3 | # Description 4 | This project is a python client library called *sap-xssec* for validation of *OAuth access tokens* issued by the *XSUAA*. 5 | 6 | ### OAuth Authorization Code Flow 7 | The typical web application use the OAuth authorization code flow for authentication, which is described as follows: 8 | 1. A user accesses the web application using a browser. 9 | 2. The web application (in typical SAP Cloud Platform applications, this is an application router) acts as OAuth client and redirects 10 | to the OAuth server for authorization. 11 | 3. Upon authentication, the web application uses the code issued by the authorization server to request an access token. 12 | 4. The web application uses the access token to request data from the OAuth resource server. 13 | The OAuth resource server validates the token using online or offline validation. 14 | For this validation libraries like sap-xssec are used. 15 | 16 | 17 | ![alt text](https://raw.githubusercontent.com/SAP/cloud-security-xsuaa-integration/1.4.0/images/oauth.png "OAuth authorization code flow") 18 | 19 | 20 | ### Usage 21 | 22 | For the usage of this library it is necessary to pass a JWT access token that should be validated to the library. 23 | The examples below rely on users and credentials that you should substitute with the ones in your context. 24 | 25 | The typical use case for calling this API lies from within a container when an HTTP request is received and it must 26 | be checked if the requester is authorized to execute this method. 27 | In this case, the access token is contained in the authorization header (with keyword `bearer`). 28 | You can remove the prefix `bearer` and pass the remaining string (just as in the following example as `access_token`) to the API. 29 | 30 | ```python 31 | from sap import xssec 32 | from cfenv import AppEnv 33 | 34 | env = AppEnv() 35 | uaa_service = env.get_service(name='').credentials 36 | 37 | security_context = xssec.create_security_context(access_token, uaa_service) 38 | ``` 39 | 40 | **Note:** That the example above uses module [`cfenv`](https://pypi.python.org/pypi/cfenv) to retrieve the configuration of the uaa 41 | service instance. 42 | `uaa_service` is a dict that contains the necessary client information and looks like: 43 | ``` 44 | { 45 | 'clientid' : 'example_clientid' // the id of the client 46 | 'clientsecret': 'example_clientsecret' // the secret of the client 47 | 'url': 'example_url' // the url of the uaa 48 | 'uaadomain': 'example_uaadomain' // the domain of the uaa 49 | 'verificationkey': 'example_verification key' // (optional) the key used for the verfication of the token 50 | } 51 | 52 | ``` 53 | If the `uaadomain` is set in the `uaa_service` and the `jku` and `kid` are set in the incomming token, the key is requested from the uaa. As a fallback, the `verificationkey` configured in `uaa_service` is used for offline validation. Requested keys are cached for 15 minutes to avoid extensive load on the uaa. 54 | 55 | The creation function `xssec.create_security_context` is to be used for an end-user token (e.g. for grant_type `password` 56 | or grant_type `authorization_code`) where user information is expected to be available within the token and thus within the security context. 57 | 58 | `create_security_context` also accepts a token of grant_type `client_credentials`. 59 | This leads to the creation of a limited *SecurityContext* where certain functions are not available. 60 | For more details please consult the API description in the wiki. 61 | 62 | For example, the `security_context` object can then be used to check if a user has a required scope: 63 | 64 | ``` 65 | security_context.check_scope('uaa.user') 66 | ``` 67 | 68 | or to receive the client id of a user: 69 | 70 | ``` 71 | security_context.get_clientid() 72 | ``` 73 | 74 | More details on the API can be found in the [wiki](https://github.com/SAP/cloud-pysec/wiki). 75 | ### Offline Validation 76 | 77 | sap-xssec offers offline validation of the access token, which requires no additional call to the UAA. 78 | The trust for this offline validation is created by binding the XS UAA service instance to your application. 79 | Inside the credentials section in the environment variable `VCAP_SERVICES`, the key for validation of tokens is included. 80 | By default, the offline validation check will only accept tokens intended for the same OAuth2 client in the same UAA identity zone. 81 | This makes sense and will cover the vast majority of use cases. 82 | 83 | ⚠️From version 2.1.0, the `SAP_JWT_TRUST_ACL` environment variable is no longer supported. 84 | 85 | If you want to enable another (foreign) application to use some of your application's scopes, you can add a ```granted-apps``` marker to your scope in the ```xs-security.json``` file (as in the following example). The value of the marker is a list of applications that is allowed to request a token with the denoted scope. 86 | 87 | ```JSON 88 | { 89 | "xsappname" : "sample-leave-request-app", 90 | "description" : "This sample application demos leave requests", 91 | "scopes" : [ { "name" : "$XSAPPNAME.createLR", 92 | "description" : "create leave requests" }, 93 | { "name" : "$XSAPPNAME.approveLR", 94 | "description" : "approve leave requests", 95 | "granted-apps" : ["MobileApprovals"] } 96 | ], 97 | "attributes" : [ { "name" : "costcenter", 98 | "description" : "costcenter", 99 | "valueType" : "string" 100 | } ], 101 | "role-templates": [ { "name" : "employee", 102 | "description" : "Role for creating leave requests", 103 | "scope-references" : [ "$XSAPPNAME.createLR","JobScheduler.scheduleJobs" ], 104 | "attribute-references": [ "costcenter"] }, 105 | { "name" : "manager", 106 | "description" : "Role for creating and approving leave requests", 107 | "scope-references" : [ "$XSAPPNAME.createLR","$XSAPPNAME.approveLR","JobScheduler.scheduleJobs" ], 108 | "attribute-references": [ "costcenter" ] } 109 | ] 110 | } 111 | ``` 112 | 113 | # Configuration 114 | ~~To configure whether the *sap-jwt* or the *py-jwt* library should be used for validation of the jwt token, 115 | change the `USE_SAP_PY_JWT` environment variable to `true`.~~ 116 | 117 | ⚠️From version 4.0.0, the `USE_SAP_PY_JWT` environment variable is no longer supported and therefore *py-jwt* is installed by default. 118 | 119 | # Requirements 120 | *sap-xssec* requires *python 3.8* or newer. 121 | 122 | 123 | # Download and Installation 124 | As this package is deployed to PyPI, you can simply add `sap-xssec` as a dependency to your python project or 125 | install this package by running `pip install sap-xssec`. 126 | 127 | # Known Issues 128 | # How to obtain support 129 | Open an issue in GitHub. 130 | -------------------------------------------------------------------------------- /tests/keys.py: -------------------------------------------------------------------------------- 1 | JWT_SIGNING_PRIVATE_KEY = """ 2 | -----BEGIN RSA PRIVATE KEY----- 3 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCbVBpnMyO0R8d2 4 | Kasc/f/Ziv7XPrzl6I5SXDJtFUb2LzCyA5SH49Qa5AvyGC6UtlZdTkxvAEtMQIAQ 5 | xBtxFM1VOiWSriLrsQ/ol6wckgUANsrU2LQq4xw+6LI4u8MqIMQydgbVc/dfdYI1 6 | +wJVP1ihT6VYitmv9mwi9CuLyNzOvhGTKdtMGw9oA7KA9SWKmoOulp0w7WaiY0Jt 7 | 5r+joY+ffwvETDrT0i1+AMaEvp//JWJ3mkXNlBZv72XqYK4nDDSGeE7qC3pG/3w5 8 | YO3L0bR+tYA/IR+4hb0H6ZH/a8aHJT0httam8VeLL1FVtuwznfxMKN3kkXZ0m/HL 9 | bhp10LihAgMBAAECggEAZVtbI052lPRlztBf7To9kqolozU4NFotTMcGzLGerZSb 10 | lP3LFWVwid+Xf/GRq87Tym0GaURq3iYUq1wcgAzP9DZOQEnLVbsjo2YdlEMgakRW 11 | 1M9XucibLN3RNj4nmzzoafkkenMCz9KxFiJmIlSEtDZxsbZhWHZXl/N22u9GTs0o 12 | KQNzroxI+SKxWcfrmJkOx3vL9++47/LY+Rw6dL+hkUxdxMLuhYUcYziNvRfV9o0Y 13 | Ag7Pl85xL3N8HkHr5ELL0RKHyk+vKbZ9xhAH50mxTZG8tAj9Ds0v3hQJrTmuyAS3 14 | ZJkqkhIJtWHmhLYiKju9ObLXtVgm8wdg8+vq/u1utQKBgQDhEf6Sy0+DhEYTMLN+ 15 | ioVf/rBXl8QgXbDkEoHMp+FhuYK3CdlD+pgaJq+KUc6RnHb0GeDPBcZkhRlTLxU0 16 | HtykDQFa4mcXIJaSKY8WHCF3hJLUnXYgQW+0oufXEDCORuzqgcUbEHnYpjuuzkCj 17 | FqjCkH4lNdvW8IJ56rpjBaWyRwKBgQCwrJVWLPPZMuwXHlkM1ytAC+dsq/1cRo3D 18 | by766k5u/J6xwlc3bM0LG6pHuXruBxkdKAeAkfmwCc4JSXR4JS3JNmYuQ7wbmDWp 19 | 20ABv9qFbTIt1rtEkjhV8bmamfe5qZL/0lza2KcQOZGr1wtzV/Vg384gm5oy1FSi 20 | 0isU+sCJ1wKBgQCFIAuf8Dm75MU+HJROyMhTG2ZaqR4Mtt4mSPwVfUdGcl/qvByS 21 | pOrKrQ8vlWvFnPKPN69NRHEwi7mLBlJYXdjMABVJGJk5iMEG+yXzQfhZpUTkFa8F 22 | LS9RfPn8r0rJHRKNMuzPMVOg3dJ3du+sh36SdrzmbZD29ZN3YWuVnoV/iQKBgDQM 23 | 5IJbBAx9gCjffATYb5mS6D+P/DjvYFyvqPurhCgWrPpZ8zAVEeOv5t7yulDeLnv0 24 | iyFJ4HIIsXby+SlcarzZFgmTUxweH9FHEvhw+YRNw3bVyJ5PJeHMMY5mxiEg4HoW 25 | E9017yJMk6o41NrKkzRTO3tH3IoVHEpL+P1ZUthJAoGALDuPKfHiuXuxxGF4oeyH 26 | KFFDIr991nBxUC1tB8Lff5ZStfbzTnjbzCRogsQ/pu1tBaoMQjpHhTnI3hbe/Iwf 27 | VffTGJTxapTiEwQuSY2OaSgHtUrz4qurHos+uVTWni8TuXfqkeoc1aIr4D7ulzPN 28 | O71jgCLNQW5OZD7MSn21eeU= 29 | -----END RSA PRIVATE KEY----- 30 | """ 31 | 32 | JWT_SIGNING_PUBLIC_KEY = """ 33 | -----BEGIN PUBLIC KEY----- 34 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm1QaZzMjtEfHdimrHP3/ 35 | 2Yr+1z685eiOUlwybRVG9i8wsgOUh+PUGuQL8hgulLZWXU5MbwBLTECAEMQbcRTN 36 | VTolkq4i67EP6JesHJIFADbK1Ni0KuMcPuiyOLvDKiDEMnYG1XP3X3WCNfsCVT9Y 37 | oU+lWIrZr/ZsIvQri8jczr4RkynbTBsPaAOygPUlipqDrpadMO1momNCbea/o6GP 38 | n38LxEw609ItfgDGhL6f/yVid5pFzZQWb+9l6mCuJww0hnhO6gt6Rv98OWDty9G0 39 | frWAPyEfuIW9B+mR/2vGhyU9IbbWpvFXiy9RVbbsM538TCjd5JF2dJvxy24addC4 40 | oQIDAQAB 41 | -----END PUBLIC KEY----- 42 | """ 43 | 44 | CLIENT_X509_KEY = """ 45 | -----BEGIN RSA PRIVATE KEY----- 46 | MIIJKAIBAAKCAgEA7KeODykmA+kxV5HPnmWqX+KzISeM4fgw9z7WQORGiDDTTW6B 47 | 5czTCZ5u6gCish10jTB8Ewd+VBlEHPBxB3qtm8YDmQoyFA6UP//Ut1uVHE3pW0tL 48 | 81XalekSmSEwG55BZIB+fO6ET5CJoKCu/Lew2KOw81VO3Pmss7royndB3zobfviu 49 | r+nVBm7qWLWjXHfX/USDT89GuyaNN/2iO4cH0o6bs0lrieGN32F3G9lZpgpoikMA 50 | E2HQ5HGPSf14a6pg0S2AgEQ+udyX2n2Yb+DHc6aMw3uSO2GHqR6+mSoT2yWZd4Gg 51 | vhEo8+KRMKH98Sxmi2edAf5gcYynnBkt4rxZDhhWsRThkLhieSoeNpG3TzpyKfhx 52 | FazB4izeNu6NkLO8ujviwhmZUqeK7zcqwGNmuRBy5sEH08f8JxUazVR6mZO5vzyQ 53 | hrLDRwjSn3IOjnI4RuvbIub5KZ0AHFAP8ySOljIhQuVLoHmDUy7I07lDpuCbtkOQ 54 | ND/FiKDcP4FwKjkeSDaY/qrrl2hF43rAw20O/gGrFk9kQSdv//S/3Oe0pzd+oyFG 55 | HF6a9NYZ9eNdHX695Hdot55SkPYuxGfDDiyqa/W+ppVh35W9sk0RKy/l2LAx4hUy 56 | DxZJinPz65wI6k2pTyyFtk1RVZCELxHrcyKnBPW84vcGPMD8Mp1pVLKSZIUCAwEA 57 | AQKCAgEAkwlcA2FupDlsFdubou/J1r+UyoG3T+MUEVpyZmkuxYmIj/CcNrp3WN+e 58 | TDfO3lncw6ifPneGbxwvrMdbgukfGs0CCUFDciDIzabXdIEreSWTWszCyNLL+B6T 59 | Fp9/M3m1aYtIi7jqbkEAsdOERbKf50p1NAsJ79QVEKqN3tYQEHEbCl3as6PWXqPl 60 | aO6aLGatxUefSqQfb0J5tZMaDGBOZO2EIfX5IfcJXgRBrAT0ZWzMHTOZxXEeCAHi 61 | pHcfLc3zT6Q01f0Q+AvuMaudXc3MPrf5d6+8YLatQk++3o09PwjyfEUHKXCiAD4C 62 | NbRAgmsy32SmMcnqBEL/FMa0Ms8RiLV+b4xoSt/aNjjHIANps2Kzlx9JdAh9cpJU 63 | 4vT3w0AjrI5HF7bf9GfeWHQa+txfki/ZhNZJUdi93G6IDccZHA1vfEf6FQmigkNO 64 | Fynglk/Y+6AriY9akhyObY/xtT9OrF2kfg3HXXWSLm0tkP/LNkupHP1c+AwBlFhP 65 | O9UnbzWZWjA9DRssLJ7PBPmbGlTLShTDjeNDtpDhS8KVtoUP3nxEXlg1PRTJJ1wU 66 | r2osqF7DCOKNsw4tDQ4S/AjXNzvWz2HGgYr3iSldbnUeMpnbpn5YO1RXRaAlnEMo 67 | ccE+bJYPyRRDtK0kzVO8GUdjs9+Gpa4becHc3LXzH70vOHWzQoECggEBAPrF18CN 68 | 40fJGgduHcHq39iI8n2nqOIZOU2ancoBjMPL47x6uiBMdNA/qNWoKt9Sfu/ndtgs 69 | ti6/cPmxEgS5FZ34jvc5jfGak5J7yxZZLhG3IN+ekQZ/CHzlmDp+jgpQ6sFaoyou 70 | axq6CLPGblOXNqVivSovfshyZowWoM8ICYWRJxkGgc+3+gONfFo4oh5/jBK6VZK6 71 | +MS83ZHB0HlLmlnJJWkBiboKD8zulnM94m6iC06U+Adb13+gCLwalSgzlcUoXRNl 72 | TS13cdLK+9lykQsaNtHfv4UTM817rvLTaPZtm50AMnMkmluvgy+l9LpmoCEjfAf/ 73 | 9x/XtafOZ85sqKUCggEBAPGWX/wz/t438qwhxFyI4Fg3dAFZlwcrCFkHElAXp4Tk 74 | hG13K5b/us4A4QGxkSNRysNUTV0iAcQEA6EnM2eaamzSEic8PuAH7XX/WXEERU8J 75 | kSu++FL2XRvZpWJUlgCsFXmVuCd3vvYioqjaqUNR9Jk0R2NWk/NICmlMAn6Bhl/X 76 | vqUbDTREhZdLjrmTU9+xDlwShCZ+IUIDuSZFBy1BfTN+8Pm/3xlJ7rdgGUNyOMwG 77 | P5b4t+bIYbCMazXx95lwwxYTVVl3aaFpxKh988l2g/S+e9ljYvDshtD9jZuiXzFr 78 | wILy6/DMcWs6umKIUQnzHoF3Ie6MkJcnToC8joSGJmECggEATFNpLo9BDPlVdct7 79 | PSlqxjIwdDRSf7ajrU4RFonQOUvWXeiQvAHpT/UAQz9zBRPL1OMDVhqNvL7B+Q1+ 80 | 6XcX8EPV58NZw56DqgMJthygm/28ALP3eh2yDKmo4qzgOJ2WRhF7Nfx41uKmgWk0 81 | 7TZKCJ576toX4ZSIR70fZsQQednLJ5/GZN2fN/OZVLCGD3hMvup93zIPQ3okiheO 82 | h0yOhyNkwogYTkp/sqUvn5XHVFr3zAwlTcATYCHqZq5Elb7Vp/N4GM4tZlhDiaAE 83 | dZOcN9/brZdHaI6GptUtU3UrLk4AHhIQSOFsJdnOuPLvAMj5lfyB5MFwiv2Rqah2 84 | CesijQKCAQB1m17ex/Eq/NGGOmn9IiUUzOoMPjDnrGtD681ecCV87EILiBgQSi4G 85 | WycuS+L77rVmoYOH8yYo5kteCFdd+C4XC60KrKlvJmzTJJvGCO40q9OgGDeLK6Po 86 | CuwYElVzvlI1/kzH6eNsry/AQ7Jis2L/shOOqHcd19Q5rmcIbsWbosuMVL740uK1 87 | 5HJ2pOIP44G6EMmEc6J9IJLhrnjv7xGkIAfvjRhuly8+1el2jARaTjBmrm3YS3RD 88 | HhiTClgeumY+OOszwo4JphO2cppbWPakDpb8HQXtgzeeRdDKEyGETZBKLzfs4ZnV 89 | OaHCldndnh0bqYM7PfKlotz0jtZSbXwBAoIBAA9Srb1pqRN31GqQHwnnEQQ5jMcs 90 | X71VTzijUfcO9UKOgh1is+EKYehwIgBzic9LELW7tQGqb1Dnq3WCO75XNaks8xt6 91 | qU8hljj5ls6aOPt9ecVMiwX1Fbh6YieJB1yWNWtFJdb4+LtpskPVlUMhbTwf5yW5 92 | nLEvOFctpxGVKKtS4goXEGYhWB3gMo+vPi/iaF8qW7QAHya0BSzfbR14tNc9My+r 93 | bnkl1KK6+HyY+X+i9z9YgQ2+nCccIy/sGDPa5h+KFax7M8mIspCTH2XTjUsTgwWs 94 | WyXQxuX7SMGdyVSvtm+VC7Grnt3pXPHZ4DHxTPRIJZhajZ0cQmVWU677JRk= 95 | -----END RSA PRIVATE KEY----- 96 | """ 97 | 98 | CLIENT_X509_CERTIFICATE = """ 99 | -----BEGIN CERTIFICATE----- 100 | MIIFaDCCA1ACCQD0XYB+ksxcszANBgkqhkiG9w0BAQsFADB2MQswCQYDVQQGEwJT 101 | RzESMBAGA1UECAwJU2luZ2Fwb3JlMRIwEAYDVQQHDAlTaW5nYXBvcmUxDDAKBgNV 102 | BAoMA1NBUDENMAsGA1UEAwwEdGVzdDEiMCAGCSqGSIb3DQEJARYTYWxsZW4ubGl1 103 | MDVAc2FwLmNvbTAeFw0yMTA2MDMwNTEzNDNaFw0yMjA2MDMwNTEzNDNaMHYxCzAJ 104 | BgNVBAYTAlNHMRIwEAYDVQQIDAlTaW5nYXBvcmUxEjAQBgNVBAcMCVNpbmdhcG9y 105 | ZTEMMAoGA1UECgwDU0FQMQ0wCwYDVQQDDAR0ZXN0MSIwIAYJKoZIhvcNAQkBFhNh 106 | bGxlbi5saXUwNUBzYXAuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC 107 | AgEA7KeODykmA+kxV5HPnmWqX+KzISeM4fgw9z7WQORGiDDTTW6B5czTCZ5u6gCi 108 | sh10jTB8Ewd+VBlEHPBxB3qtm8YDmQoyFA6UP//Ut1uVHE3pW0tL81XalekSmSEw 109 | G55BZIB+fO6ET5CJoKCu/Lew2KOw81VO3Pmss7royndB3zobfviur+nVBm7qWLWj 110 | XHfX/USDT89GuyaNN/2iO4cH0o6bs0lrieGN32F3G9lZpgpoikMAE2HQ5HGPSf14 111 | a6pg0S2AgEQ+udyX2n2Yb+DHc6aMw3uSO2GHqR6+mSoT2yWZd4GgvhEo8+KRMKH9 112 | 8Sxmi2edAf5gcYynnBkt4rxZDhhWsRThkLhieSoeNpG3TzpyKfhxFazB4izeNu6N 113 | kLO8ujviwhmZUqeK7zcqwGNmuRBy5sEH08f8JxUazVR6mZO5vzyQhrLDRwjSn3IO 114 | jnI4RuvbIub5KZ0AHFAP8ySOljIhQuVLoHmDUy7I07lDpuCbtkOQND/FiKDcP4Fw 115 | KjkeSDaY/qrrl2hF43rAw20O/gGrFk9kQSdv//S/3Oe0pzd+oyFGHF6a9NYZ9eNd 116 | HX695Hdot55SkPYuxGfDDiyqa/W+ppVh35W9sk0RKy/l2LAx4hUyDxZJinPz65wI 117 | 6k2pTyyFtk1RVZCELxHrcyKnBPW84vcGPMD8Mp1pVLKSZIUCAwEAATANBgkqhkiG 118 | 9w0BAQsFAAOCAgEAqhOiUG10XMcpJovTSByw0P5XPPduElKOygynGad6vz6r4Ewx 119 | vIl21DB26PqQA+WWoKtEvPWQtDPYsv+L/EVoYqOvDeU36utftM+Fv2tUI5IKsPc7 120 | 4cWA1g+y3ieVM+5EXm5xCgobI07XxxCou6OuAK+wzPzOMmfxtz4w6OxVirjcZBto 121 | BrOK9zRQDTBY8hV4ZVEzF/eUPMyAgUt/N+zzikwE9y5sbokTgsoLGAJ72oPm6S3b 122 | YF8jRg+nL7rNewm2GXG6Qc/z2hoZvGc9lN4iJYpux2AUNRVTpZpzlsONsgcYxpaP 123 | auhzu0wSA85U/CJldK2lmnk5bSYACqXC+K/OSw0j61O4UQyvKjFZidaoEFBu+TTY 124 | KGXsCeC6X8mlkKBmpPyuyVi8BA6/fHj7X+cdwAc0kUKtQURimv5L6YA8vxlTtGrg 125 | yTiazafTg4+p//nP0cAQ7gV0MsvRbyNRW3HN4xG+yXv2B+5/5MwvNgRZaldBABlu 126 | rR0PSGTQlNDxFoPnmdj87gaJfZPpjOLtGgfKaFqbLlcjE1KvBLlRTzfRzN70l85X 127 | 7ovwBkw8fcwCbFpTbUsEJncZJmdS9XZsr7rvezS3xW5FU0yGsYa8mwI9OU3FGdFE 128 | FaH8sgBv0ft/Uey4VJCimkJO/AIZrbXEk2wiy5jLVU8XzPm42D82osnALJA= 129 | -----END CERTIFICATE----- 130 | """ 131 | -------------------------------------------------------------------------------- /tests/test_key_cache.py: -------------------------------------------------------------------------------- 1 | from httpx import HTTPStatusError, Response 2 | from httpx import TimeoutException 3 | 4 | from sap.xssec import constants 5 | from sap.xssec.key_cache import KeyCache 6 | import unittest 7 | try: 8 | from unittest.mock import MagicMock, patch 9 | except ImportError: 10 | from mock import MagicMock, patch 11 | 12 | from tests.http_responses import * 13 | 14 | MOCKED_CURRENT_TIME = 915148801.25 15 | threadErrors = False 16 | 17 | 18 | @patch('time.time', return_value=MOCKED_CURRENT_TIME) 19 | @patch('sap.xssec.key_cache.httpx.get', return_value=MagicMock()) 20 | class CacheTest(unittest.TestCase): 21 | 22 | def setUp(self): 23 | self.cache = KeyCache() 24 | self.mock = MagicMock() 25 | 26 | def test_empty_cache_load_key(self, mock_requests, mock_time): 27 | mock_requests.return_value = self.mock 28 | self.mock.json.return_value = HTTP_SUCCESS 29 | 30 | key = self.cache.load_key("jku1", "key-id-1") 31 | 32 | self.assert_key_equal(KEY_ID_1, key) 33 | mock_requests.assert_called_once_with("jku1", timeout=constants.HTTP_TIMEOUT_IN_SECONDS) 34 | 35 | def test_not_hit_load_key(self, mock_requests, mock_time): 36 | mock_requests.return_value = self.mock 37 | self.mock.json.side_effect = [HTTP_SUCCESS_DUMMY, HTTP_SUCCESS] 38 | self.cache.load_key("jku2", "key-id-1") 39 | 40 | key = self.cache.load_key("jku1", "key-id-1") 41 | 42 | self.assert_key_equal(KEY_ID_1, key) 43 | self.assertEqual(2, mock_requests.call_count) 44 | 45 | def test_hit_do_not_load_key(self, mock_requests, mock_time): 46 | mock_requests.return_value = self.mock 47 | self.mock.json.side_effect = [HTTP_SUCCESS_DUMMY, HTTP_SUCCESS] 48 | 49 | mock_time.return_value = MOCKED_CURRENT_TIME - (constants.KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES - 1) * 60 50 | self.cache.load_key("jku1", "key-id-1") 51 | mock_time.return_value = MOCKED_CURRENT_TIME 52 | 53 | key = self.cache.load_key("jku1", "key-id-1") 54 | 55 | self.assert_key_equal("dummy-key", key) 56 | self.assertEqual(1, mock_requests.call_count) 57 | 58 | def test_expired_key_load_key(self, mock_requests, mock_time): 59 | mock_requests.return_value = self.mock 60 | self.mock.json.side_effect = [HTTP_SUCCESS_DUMMY, HTTP_SUCCESS] 61 | 62 | mock_time.return_value = MOCKED_CURRENT_TIME - (constants.KEYCACHE_DEFAULT_CACHE_ENTRY_EXPIRATION_TIME_IN_MINUTES + 1) * 60 63 | self.cache.load_key("jku1", "key-id-1") 64 | mock_time.return_value = MOCKED_CURRENT_TIME 65 | 66 | key = self.cache.load_key("jku1", "key-id-1") 67 | 68 | self.assert_key_equal(KEY_ID_1, key) 69 | self.assertEqual(2, mock_requests.call_count) 70 | 71 | def test_kid_does_not_match(self, mock_requests, mock_time): 72 | mock_requests.return_value = self.mock 73 | self.mock.json.side_effect = [HTTP_SUCCESS_DUMMY, HTTP_SUCCESS] 74 | self.cache.load_key("jku2", "key-id-1") 75 | 76 | key = self.cache.load_key("jku2", "key-id-0") 77 | 78 | self.assert_key_equal(KEY_ID_0, key) 79 | self.assertEqual(2, mock_requests.call_count) 80 | 81 | def test_cache_max_size(self, mock_requests, mock_time): 82 | mock_requests.return_value = self.mock 83 | self.mock.json.return_value = HTTP_SUCCESS_DUMMY 84 | for i in range(0, constants.KEYCACHE_DEFAULT_CACHE_SIZE): 85 | self.cache.load_key("jku-" + str(i), "key-id-1") 86 | 87 | self.assertEqual(len(self.cache._cache), constants.KEYCACHE_DEFAULT_CACHE_SIZE) 88 | self.assertTrue(KeyCache._create_cache_key("jku-0", "key-id-1") in self.cache._cache) 89 | 90 | self.mock.json.return_value = HTTP_SUCCESS 91 | key = self.cache.load_key("jku1", "key-id-0") 92 | 93 | self.assert_key_equal(KEY_ID_0, key) 94 | self.assertEqual(constants.KEYCACHE_DEFAULT_CACHE_SIZE + 1, mock_requests.call_count) 95 | self.assertEqual(len(self.cache._cache), constants.KEYCACHE_DEFAULT_CACHE_SIZE) 96 | # assert that least recently inserted key got deleted 97 | self.assertFalse(KeyCache._create_cache_key("jku-0", "key-id-1") in self.cache._cache) 98 | 99 | def test_update_increases_insertion_order(self, mock_requests, mock_time): 100 | mock_requests.return_value = self.mock 101 | self.mock.json.return_value = HTTP_SUCCESS_DUMMY 102 | for i in range(0, constants.KEYCACHE_DEFAULT_CACHE_SIZE): 103 | self.cache.load_key("jku-" + str(i), "key-id-1") 104 | self.assertEqual(len(self.cache._cache), constants.KEYCACHE_DEFAULT_CACHE_SIZE) 105 | self.assertTrue(KeyCache._create_cache_key("jku-0", "key-id-1") in self.cache._cache) 106 | 107 | # first cache entry is invalid -> must be updated 108 | self.cache._cache[KeyCache._create_cache_key("jku-0", "key-id-1")].insert_timestamp = 0 109 | 110 | self.mock.json.return_value = HTTP_SUCCESS 111 | # update first cache entry -> should not deleted if new key is added 112 | self.cache.load_key("jku-0", "key-id-1") 113 | self.assertTrue(KeyCache._create_cache_key("jku-0", "key-id-1") in self.cache._cache) 114 | self.assertTrue(KeyCache._create_cache_key("jku-1", "key-id-1") in self.cache._cache) 115 | 116 | # add new key 117 | self.cache.load_key("jku1", "key-id-0") 118 | 119 | self.assertTrue(KeyCache._create_cache_key("jku-0", "key-id-1") in self.cache._cache) 120 | self.assertFalse(KeyCache._create_cache_key("jku-1", "key-id-1") in self.cache._cache) 121 | self.assertEqual(len(self.cache._cache), constants.KEYCACHE_DEFAULT_CACHE_SIZE) 122 | 123 | @patch('sap.xssec.key_cache.CacheEntry.is_valid', return_value=False) 124 | def test_parallel_access_works(self, mock_valid, mock_requests, mock_time): 125 | # All entries are invalid, so each load updates the cache. 126 | # This leads to problems if the threads are not correctly synchronized. 127 | import threading 128 | mock_requests.return_value = self.mock 129 | self.mock.json.return_value = HTTP_SUCCESS 130 | 131 | def thread_target(): 132 | for _ in range(0, 100): 133 | try: 134 | self.cache.load_key("jku1", "key-id-0") 135 | except Exception: 136 | global threadErrors 137 | threadErrors = True 138 | raise 139 | 140 | threads = [] 141 | for _ in range(0, 10): 142 | t = threading.Thread(target=thread_target, args=[]) 143 | threads.append(t) 144 | t.start() 145 | for t in threads: 146 | t.join() 147 | 148 | self.assertFalse(threadErrors) 149 | 150 | def test_get_returns_empty(self, mock_requests, mock_time): 151 | mock_requests.return_value = self.mock 152 | self.mock.json.return_value = {} 153 | 154 | with self.assertRaises(ValueError): 155 | self.cache.load_key("jku1", "key-id-1") 156 | 157 | mock_requests.assert_called_once_with("jku1", timeout=constants.HTTP_TIMEOUT_IN_SECONDS) 158 | 159 | def test_no_matching_kid(self, mock_requests, mock_time): 160 | mock_requests.return_value = self.mock 161 | self.mock.json.return_value = HTTP_SUCCESS 162 | 163 | with self.assertRaises(ValueError): 164 | self.cache.load_key("jku1", "key-id-3") 165 | 166 | mock_requests.assert_called_once_with("jku1", timeout=constants.HTTP_TIMEOUT_IN_SECONDS) 167 | 168 | def assert_key_equal(self, key1, key2): 169 | self.assertEqual(strip_white_space(key1), strip_white_space(key2)) 170 | 171 | def test_timeout_retry(self, mock_requests, mock_time): 172 | # mock_requests.side_effect = [Timeout(), self.mock] 173 | 174 | exc = TimeoutException('timeout_retry test case', request=None) 175 | mock_requests.side_effect = [exc, self.mock] 176 | self.mock.json.return_value = HTTP_SUCCESS 177 | 178 | key = self.cache.load_key("jku1", "key-id-1") 179 | 180 | self.assert_key_equal(KEY_ID_1, key) 181 | self.assertEqual(2, mock_requests.call_count) 182 | 183 | def test_timeout_retry_max(self, mock_requests, mock_time): 184 | exc = TimeoutException('retry_max test case', request=None) 185 | mock_requests.side_effect = [exc, exc, exc, self.mock] 186 | self.mock.json.return_value = HTTP_SUCCESS 187 | 188 | key = self.cache.load_key("jku1", "key-id-1") 189 | 190 | self.assert_key_equal(KEY_ID_1, key) 191 | self.assertEqual(4, mock_requests.call_count) 192 | 193 | def test_timeout_retry_fail(self, mock_requests, mock_time): 194 | exc = TimeoutException('retry_fail test case', request=None) 195 | mock_requests.side_effect = 4 * [exc] 196 | 197 | with self.assertRaises(TimeoutException): 198 | self.cache.load_key("jku1", "key-id-1") 199 | self.assertEqual(4, mock_requests.call_count) 200 | 201 | def test_http_retry_(self, mock_requests, mock_time): 202 | response = Response(status_code=502) 203 | mock_requests.side_effect = [HTTPStatusError(message=..., request=..., response=response), self.mock] 204 | self.mock.json.return_value = HTTP_SUCCESS 205 | 206 | key = self.cache.load_key("jku1", "key-id-1") 207 | self.assertEqual(2, mock_requests.call_count) 208 | self.assert_key_equal(KEY_ID_1, key) 209 | 210 | 211 | def strip_white_space(key): 212 | return key.replace(" ", "").replace("\t", "").replace("\n", "") 213 | -------------------------------------------------------------------------------- /tests/uaa_configs.py: -------------------------------------------------------------------------------- 1 | ''' test uaa configurations ''' 2 | from tests.keys import JWT_SIGNING_PUBLIC_KEY 3 | 4 | INVALID = { 5 | 'uaa_url_undefined': { 6 | 'clientid': 'xs2.node', 7 | 'clientsecret': 'nodeclientsecret', 8 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 9 | 'xsappname': 'node_unittest_app', 10 | 'identityzone': 'test-idz', 11 | 'tags': ['xsuaa_url_undefined'], 12 | 'uaadomain': 'api.cf.test.com' 13 | }, 14 | 'uaa_clientid_undefined': { 15 | 'url': 'http://sap-login-test.cfapps.neo.ondemand.com', 16 | 'clientsecret': 'nodeclientsecret', 17 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 18 | 'xsappname': 'node_unittest_app', 19 | 'identityzone': 'test-idz', 20 | 'tags': ['xsuaa_clientid_undefined'], 21 | 'uaadomain': 'api.cf.test.com' 22 | }, 23 | 'uaa_clientsecret_and_certificate_undefined': { 24 | 'url': 'http://sap-login-test.cfapps.neo.ondemand.com', 25 | 'clientid': 'xs2.node', 26 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 27 | 'xsappname': 'node_unittest_app', 28 | 'identityzone': 'test-idz', 29 | 'tags': ['xsuaa_clientsecret_undefined'], 30 | 'uaadomain': 'api.cf.test.com' 31 | }, 32 | 'uaa_xsappname_undefined': { 33 | 'url': 'http://sap-login-test.cfapps.neo.ondemand.com', 34 | 'clientid': 'xs2.node', 35 | 'clientsecret': 'nodeclientsecret', 36 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 37 | 'identityzone': 'test-idz', 38 | 'tags': ['xsuaa_clientsecret_undefined'], 39 | 'uaadomain': 'api.cf.test.com' 40 | }, 41 | 'uaa_broker_plan_wrong_suffix': { 42 | 'clientid': 'sb-xssectest!t4', 43 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 44 | 'xsappname': "sb-tenant-test!t13", 45 | 46 | 'identityzone': 'test-idz', 47 | 'trustedclientidsuffix': 'hugo', 48 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHldI' 49 | 'FUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 50 | 'url': 'https://test.home.me/uaa', 51 | 'tags': ['xsuaa_broker_plan_wrong_suffix'], 52 | 'uaadomain': 'api.cf.test.com' 53 | }, 54 | 'uaa_verificationkey_invalid': { 55 | 'clientid': 'sb-clone2!b1|LR-master!b1', 56 | 'verificationkey': 'invalid', 57 | 'xsappname': 'uaa', 58 | 'identityzone': 'paas', 59 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHldI' 60 | 'FUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 61 | 'url': 'http://paas.localhost:8080/uaa-security', 62 | 'tags': ['xsuaa_application_plan'], 63 | 'uaadomain': 'api.cf.test.com' 64 | }, 65 | 'uaa_mtls_certificate_undefined': { 66 | 'url': 'http://sap-login-test.cfapps.neo.ondemand.com', 67 | 'clientid': 'xs2.node', 68 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 69 | 'xsappname': 'node_unittest_app', 70 | 'identityzone': 'test-idz', 71 | 'tags': ['xsuaa_clientsecret_undefined'], 72 | 'uaadomain': 'api.cf.test.com', 73 | 'credential-type': 'x509', 74 | 'key': 'some-key' 75 | }, 76 | 'uaa_mtls_key_undefined': { 77 | 'url': 'http://sap-login-test.cfapps.neo.ondemand.com', 78 | 'clientid': 'xs2.node', 79 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 80 | 'xsappname': 'node_unittest_app', 81 | 'identityzone': 'test-idz', 82 | 'tags': ['xsuaa_clientsecret_undefined'], 83 | 'uaadomain': 'api.cf.test.com', 84 | 'credential-type': 'x509', 85 | 'certificate': 'some-cert' 86 | }, 87 | } 88 | VALID = { 89 | 'uaa': { 90 | 'clientid': 'sb-xssectest', 91 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 92 | 'xsappname': 'uaa', 93 | 'identityzone': 'test-idz-name', 94 | 'identityzoneid': 'test-idz', 95 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 96 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 97 | 'url': 'https://test.home.me/uaa', 98 | 'tags': ['xsuaa'], 99 | 'uaadomain': 'api.cf.test.com' 100 | }, 101 | 'uaa_foreign_idz': { 102 | 'clientid': 'sb-xssectest', 103 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 104 | 'xsappname': 'uaa', 105 | 'identityzoneid': 'foreign-idz', 106 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 107 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 108 | 'url': 'https://test.home.me/uaa', 109 | 'tags': ['xsuaa_foreign_idz'], 110 | 'uaadomain': 'api.cf.test.com' 111 | }, 112 | 'uaa_foreign_clientid': { 113 | 'clientid': 'foreign-clientid', 114 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 115 | 'xsappname': 'uaa', 116 | 'identityzone': 'test-idz', 117 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHldI' 118 | 'FUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 119 | 'url': 'https://test.home.me/uaa', 120 | 'tags': ['xsuaa_foreign_clientid'], 121 | 'uaadomain': 'api.cf.test.com' 122 | }, 123 | 'uaa_foreign_idz_clientid': { 124 | 'clientid': 'foreign-clientid', 125 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 126 | 'xsappname': 'uaa', 127 | 'identityzone': 'foreign-idz', 128 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 129 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 130 | 'url': 'https://test.home.me/uaa', 131 | 'tags': ['xsuaa_foreign_idz_clientid'], 132 | 'uaadomain': 'api.cf.test.com' 133 | }, 134 | 'uaa_cc': { 135 | 'clientid': 'sb-xssectest', 136 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 137 | 'xsappname': 'uaa', 138 | 'identityzone': 'test-idz', 139 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 140 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 141 | 'url': 'https://test.home.me/uaa', 142 | 'tags': ['xsuaa_cc'], 143 | 'uaadomain': 'api.cf.test.com' 144 | }, 145 | 'uaa_bearer': { 146 | 'clientid': 'sb-xssectest', 147 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 148 | 'xsappname': 'admin', 149 | 'identityzone': 'test-idz', 150 | 'clientsecret': 'UBHlAbnLhn+PiTc7xWG7s1yb+bTkXOjvDtBRbDykXLS2c' 151 | 'DQIMjSzXZccV6dweeIZJphnqhqJ5MVz\niAdePOsZEQ==', 152 | 'url': 'https://mo-dd9396c2c.mo.sap.corp:30032/uaa-security', 153 | 'tags': ['xsuaa_bearer'], 154 | 'uaadomain': 'api.cf.test.com' 155 | }, 156 | 'uaa_broker_plan': { 157 | 'clientid': 'sb-xssectest!b4', 158 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 159 | 'xsappname': 'uaa', 160 | 'identityzone': 'test-idz', 161 | 'trustedclientidsuffix': '|sb-xssectest!b4', 162 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHldI' 163 | 'FUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 164 | 'url': 'https://test.home.me/uaa', 165 | 'tags': ['xsuaa_broker_plan'], 166 | 'uaadomain': 'api.cf.test.com' 167 | }, 168 | 'uaa_application_plan': { 169 | 'clientid': 'sb-xssectest!t4', 170 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 171 | 'xsappname': 'uaa', 172 | 'identityzone': 'paas-idz', 173 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHldI' 174 | 'FUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 175 | 'url': 'https://test.home.me/uaa', 176 | 'tags': ['xsuaa_application_plan'], 177 | 'uaadomain': 'api.cf.test.com' 178 | }, 179 | 'uaa_new_token_structure': { 180 | 'clientid': 'sb-clone2!b1|LR-master!b1', 181 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 182 | 'xsappname': 'uaa', 183 | 'identityzone': 'paas', 184 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHldI' 185 | 'FUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 186 | 'url': 'http://paas.localhost:8080/uaa-security', 187 | 'tags': ['xsuaa_application_plan'], 188 | 'uaadomain': 'api.cf.test.com' 189 | }, 190 | 'uaa_no_verification_key': { 191 | 'clientid': 'sb-xssectest', 192 | 'xsappname': 'uaa', 193 | 'identityzone': 'test-idz-name', 194 | 'identityzoneid': 'test-idz', 195 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 196 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 197 | 'url': 'https://test.me/uaa', 198 | 'tags': ['xsuaa'], 199 | 'uaadomain': 'api.cf.test.com' 200 | }, 201 | 'uaa_no_verification_key_other_domain': { 202 | 'clientid': 'sb-xssectest', 203 | 'xsappname': 'uaa', 204 | 'identityzone': 'test-idz-name', 205 | 'identityzoneid': 'test-idz', 206 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 207 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 208 | 'url': 'https://test.me/uaa', 209 | 'tags': ['xsuaa'], 210 | 'uaadomain': 'api.cf2.test.com' 211 | }, 212 | 'uaa_xsa_environment': { 213 | 'clientid': 'sb-xssectest', 214 | 'xsappname': 'uaa', 215 | 'identityzone': 'uaa', 216 | 'identityzoneid': 'uaa', 217 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 218 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 219 | 'url': 'http://localhost:8080/uaa', 220 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 221 | 'tags': ['xsuaa'] 222 | }, 223 | 'uaa_xsa_with_newlines': { 224 | 'clientid': 'sb-xssectest', 225 | 'xsappname': 'uaa', 226 | 'identityzone': 'uaa', 227 | 'identityzoneid': 'uaa', 228 | 'clientsecret': 'z431EZmJWiuA/yINKXGewGR/wo99JKiVKAzG7yRyUHld' 229 | 'IFUBiZx5SOMxvS2nqwwDzK6sqX2Hx2i2\nadgJjtIqgA==', 230 | 'url': 'http://localhost:8080/uaa', 231 | 'verificationkey': JWT_SIGNING_PUBLIC_KEY, 232 | 'tags': ['xsuaa'] 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /LICENSES/Apache-2.0.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, 6 | AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, and distribution 13 | as defined by Sections 1 through 9 of this document. 14 | 15 | 16 | 17 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 18 | owner that is granting the License. 19 | 20 | 21 | 22 | "Legal Entity" shall mean the union of the acting entity and all other entities 23 | that control, are controlled by, or are under common control with that entity. 24 | For the purposes of this definition, "control" means (i) the power, direct 25 | or indirect, to cause the direction or management of such entity, whether 26 | by contract or otherwise, or (ii) ownership of fifty percent (50%) or more 27 | of the outstanding shares, or (iii) beneficial ownership of such entity. 28 | 29 | 30 | 31 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions 32 | granted by this License. 33 | 34 | 35 | 36 | "Source" form shall mean the preferred form for making modifications, including 37 | but not limited to software source code, documentation source, and configuration 38 | files. 39 | 40 | 41 | 42 | "Object" form shall mean any form resulting from mechanical transformation 43 | or translation of a Source form, including but not limited to compiled object 44 | code, generated documentation, and conversions to other media types. 45 | 46 | 47 | 48 | "Work" shall mean the work of authorship, whether in Source or Object form, 49 | made available under the License, as indicated by a copyright notice that 50 | is included in or attached to the work (an example is provided in the Appendix 51 | below). 52 | 53 | 54 | 55 | "Derivative Works" shall mean any work, whether in Source or Object form, 56 | that is based on (or derived from) the Work and for which the editorial revisions, 57 | annotations, elaborations, or other modifications represent, as a whole, an 58 | original work of authorship. For the purposes of this License, Derivative 59 | Works shall not include works that remain separable from, or merely link (or 60 | bind by name) to the interfaces of, the Work and Derivative Works thereof. 61 | 62 | 63 | 64 | "Contribution" shall mean any work of authorship, including the original version 65 | of the Work and any modifications or additions to that Work or Derivative 66 | Works thereof, that is intentionally submitted to Licensor for inclusion in 67 | the Work by the copyright owner or by an individual or Legal Entity authorized 68 | to submit on behalf of the copyright owner. For the purposes of this definition, 69 | "submitted" means any form of electronic, verbal, or written communication 70 | sent to the Licensor or its representatives, including but not limited to 71 | communication on electronic mailing lists, source code control systems, and 72 | issue tracking systems that are managed by, or on behalf of, the Licensor 73 | for the purpose of discussing and improving the Work, but excluding communication 74 | that is conspicuously marked or otherwise designated in writing by the copyright 75 | owner as "Not a Contribution." 76 | 77 | 78 | 79 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 80 | of whom a Contribution has been received by Licensor and subsequently incorporated 81 | within the Work. 82 | 83 | 2. Grant of Copyright License. Subject to the terms and conditions of this 84 | License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 85 | no-charge, royalty-free, irrevocable copyright license to reproduce, prepare 86 | Derivative Works of, publicly display, publicly perform, sublicense, and distribute 87 | the Work and such Derivative Works in Source or Object form. 88 | 89 | 3. Grant of Patent License. Subject to the terms and conditions of this License, 90 | each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 91 | no-charge, royalty-free, irrevocable (except as stated in this section) patent 92 | license to make, have made, use, offer to sell, sell, import, and otherwise 93 | transfer the Work, where such license applies only to those patent claims 94 | licensable by such Contributor that are necessarily infringed by their Contribution(s) 95 | alone or by combination of their Contribution(s) with the Work to which such 96 | Contribution(s) was submitted. If You institute patent litigation against 97 | any entity (including a cross-claim or counterclaim in a lawsuit) alleging 98 | that the Work or a Contribution incorporated within the Work constitutes direct 99 | or contributory patent infringement, then any patent licenses granted to You 100 | under this License for that Work shall terminate as of the date such litigation 101 | is filed. 102 | 103 | 4. Redistribution. You may reproduce and distribute copies of the Work or 104 | Derivative Works thereof in any medium, with or without modifications, and 105 | in Source or Object form, provided that You meet the following conditions: 106 | 107 | (a) You must give any other recipients of the Work or Derivative Works a copy 108 | of this License; and 109 | 110 | (b) You must cause any modified files to carry prominent notices stating that 111 | You changed the files; and 112 | 113 | (c) You must retain, in the Source form of any Derivative Works that You distribute, 114 | all copyright, patent, trademark, and attribution notices from the Source 115 | form of the Work, excluding those notices that do not pertain to any part 116 | of the Derivative Works; and 117 | 118 | (d) If the Work includes a "NOTICE" text file as part of its distribution, 119 | then any Derivative Works that You distribute must include a readable copy 120 | of the attribution notices contained within such NOTICE file, excluding those 121 | notices that do not pertain to any part of the Derivative Works, in at least 122 | one of the following places: within a NOTICE text file distributed as part 123 | of the Derivative Works; within the Source form or documentation, if provided 124 | along with the Derivative Works; or, within a display generated by the Derivative 125 | Works, if and wherever such third-party notices normally appear. The contents 126 | of the NOTICE file are for informational purposes only and do not modify the 127 | License. You may add Your own attribution notices within Derivative Works 128 | that You distribute, alongside or as an addendum to the NOTICE text from the 129 | Work, provided that such additional attribution notices cannot be construed 130 | as modifying the License. 131 | 132 | You may add Your own copyright statement to Your modifications and may provide 133 | additional or different license terms and conditions for use, reproduction, 134 | or distribution of Your modifications, or for any such Derivative Works as 135 | a whole, provided Your use, reproduction, and distribution of the Work otherwise 136 | complies with the conditions stated in this License. 137 | 138 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 139 | Contribution intentionally submitted for inclusion in the Work by You to the 140 | Licensor shall be under the terms and conditions of this License, without 141 | any additional terms or conditions. Notwithstanding the above, nothing herein 142 | shall supersede or modify the terms of any separate license agreement you 143 | may have executed with Licensor regarding such Contributions. 144 | 145 | 6. Trademarks. This License does not grant permission to use the trade names, 146 | trademarks, service marks, or product names of the Licensor, except as required 147 | for reasonable and customary use in describing the origin of the Work and 148 | reproducing the content of the NOTICE file. 149 | 150 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to 151 | in writing, Licensor provides the Work (and each Contributor provides its 152 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 153 | KIND, either express or implied, including, without limitation, any warranties 154 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR 155 | A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness 156 | of using or redistributing the Work and assume any risks associated with Your 157 | exercise of permissions under this License. 158 | 159 | 8. Limitation of Liability. In no event and under no legal theory, whether 160 | in tort (including negligence), contract, or otherwise, unless required by 161 | applicable law (such as deliberate and grossly negligent acts) or agreed to 162 | in writing, shall any Contributor be liable to You for damages, including 163 | any direct, indirect, special, incidental, or consequential damages of any 164 | character arising as a result of this License or out of the use or inability 165 | to use the Work (including but not limited to damages for loss of goodwill, 166 | work stoppage, computer failure or malfunction, or any and all other commercial 167 | damages or losses), even if such Contributor has been advised of the possibility 168 | of such damages. 169 | 170 | 9. Accepting Warranty or Additional Liability. While redistributing the Work 171 | or Derivative Works thereof, You may choose to offer, and charge a fee for, 172 | acceptance of support, warranty, indemnity, or other liability obligations 173 | and/or rights consistent with this License. However, in accepting such obligations, 174 | You may act only on Your own behalf and on Your sole responsibility, not on 175 | behalf of any other Contributor, and only if You agree to indemnify, defend, 176 | and hold each Contributor harmless for any liability incurred by, or claims 177 | asserted against, such Contributor by reason of your accepting any such warranty 178 | or additional liability. END OF TERMS AND CONDITIONS 179 | 180 | APPENDIX: How to apply the Apache License to your work. 181 | 182 | To apply the Apache License to your work, attach the following boilerplate 183 | notice, with the fields enclosed by brackets "[]" replaced with your own identifying 184 | information. (Don't include the brackets!) The text should be enclosed in 185 | the appropriate comment syntax for the file format. We also recommend that 186 | a file or class name and description of purpose be included on the same "printed 187 | page" as the copyright notice for easier identification within third-party 188 | archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | 194 | you may not use this file except in compliance with the License. 195 | 196 | You may obtain a copy of the License at 197 | 198 | http://www.apache.org/licenses/LICENSE-2.0 199 | 200 | Unless required by applicable law or agreed to in writing, software 201 | 202 | distributed under the License is distributed on an "AS IS" BASIS, 203 | 204 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 205 | 206 | See the License for the specific language governing permissions and 207 | 208 | limitations under the License. 209 | -------------------------------------------------------------------------------- /tests/jwt_payloads.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=line-too-long 2 | ''' Test jwt tokens ''' 3 | 4 | 5 | def merge(dict1, dict2): 6 | result = dict1.copy() 7 | result.update(dict2) 8 | return result 9 | 10 | 11 | USER_TOKEN = { 12 | "jti": "c6831125-1ed6-41b0-8ea8-e60a341a2787", 13 | "sub": "425130", 14 | "scope": [ 15 | "openid", 16 | "uaa.resource" 17 | ], 18 | "client_id": "sb-xssectest", 19 | "cid": "sb-xssectest", 20 | "azp": "sb-xssectest", 21 | "grant_type": "password", 22 | "user_id": "425130", 23 | "user_name": "NODETESTUSER", 24 | "email": "Nodetest@sap.com", 25 | "origin": "testidp", 26 | "given_name": "NodetestFirstName", 27 | "family_name": "NodetestLastName", 28 | "iat": 1470815434, 29 | "exp": 2101535434, 30 | "iss": "http://paas.localhost:8080/uaa/oauth/token", 31 | "zid": "test-idz", 32 | "hdb.nameduser.saml": "TST-samlou8r3R0WBHG1bp4KKOx1PyVOiYA=LbRKv1r/h7IMmiSyx10WkM7JuekrmwyVNsB53pkFRnrjCGWtmFkQsknsL7eTUN4+gcJGW0qGTUmvUkfXE1O8rf2CmTcC01cYsGAZWbNpOLNmpP9gG6572pveRqjTXLGSilM2ejJiylq2JnFLhXpgrnTbCvQW6a9JTpRpvMz8SiSodxax7rJw7C0yZzUq862M5yNjdoIHhEkngMcC5LDDhfpf6TkQMsyVcMamDqjTS7WTgvkQKl5pkOPKEuhTjCR7P7KAekeDmYoqs7yEZrrdKEixSY4i5F3weM+dw+A1ue9jF2KmeRvjoxs2hwfsWwUvCxy+2Jhr54vatmweG8dI0Q==NODETESTUSERurn:oasis:names:tc:SAML:2.0:ac:classes:Password", 33 | "az_attr": { 34 | "external_group": "domaingroup1", 35 | "external_id": "abcd1234" 36 | }, 37 | "ext_attr": { 38 | "serviceinstanceid": "abcd1234", 39 | "zdn": "paas" 40 | }, 41 | "xs.system.attributes": { 42 | "xs.saml.groups": [ 43 | "Canary_RoleBuilder" 44 | ], 45 | "xs.rolecollections": [] 46 | }, 47 | "xs.user.attributes": { 48 | "country": [ 49 | "USA" 50 | ] 51 | }, 52 | "aud": [ 53 | "sb-xssectest", 54 | "openid" 55 | ] 56 | } 57 | 58 | USER_TOKEN_NO_ATTR = merge(USER_TOKEN, { 59 | "ext_attr": { 60 | "zdn": "paas" 61 | }, 62 | "xs.user.attributes": None 63 | }) 64 | 65 | USER_TOKEN_NAMES_IN_EXT_ATTR = merge(USER_TOKEN, { 66 | "ext_attr": { 67 | "given_name": "NodetestFirstNameExtAttr", 68 | "family_name": "NodetestLastNameExtAttr" 69 | } 70 | }) 71 | 72 | USER_TOKEN_EXPIRED = merge(USER_TOKEN, { 73 | "exp": 946684800, 74 | }) 75 | 76 | USER_TOKEN_JWT_BEARER_FOR_CLIENT = merge(USER_TOKEN, { 77 | "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 78 | "scope": [ 79 | "openid", 80 | ] 81 | }) 82 | 83 | USER_SAML_BEARER_TOKEN = merge(USER_TOKEN, { 84 | "grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer", 85 | "scope": [ 86 | "openid" 87 | ], 88 | "ext_attr": None 89 | }) 90 | 91 | USER_APPLICATION_PLAN_TOKEN = merge(USER_TOKEN, { 92 | "client_id": "sb-xssectest!t4", 93 | "cid": "sb-xssectest!t4", 94 | "ext_attr": {}, 95 | }) 96 | 97 | INVALID_TRUSTED_APPLICATION_PLAN_TOKEN = { 98 | "jti": "a3b643f88e964bcab2f7e996db4b5183", 99 | "ext_attr": { 100 | "enhancer": "XSUAA", 101 | "zdn": "api" 102 | }, 103 | "sub": "sb-tenant-test!t13", 104 | "scope": [ 105 | "dox-ui-poc!b7857.Callback" 106 | ], 107 | "client_id": "sb-tenant-test!t13", 108 | "cid": "sb-tenant-test!t13", 109 | "azp": "sb-tenant-test!t13", 110 | "grant_type": "client_credentials", 111 | "rev_sig": "f2a1a9d1", 112 | "iss": "https://api.cf.test.com/uaa/oauth/token", 113 | "zid": "api", 114 | "aud": [ 115 | "dox-ui-poc!b7857", 116 | "sb-tenant-test!t13" 117 | ] 118 | } 119 | 120 | CLIENT_CREDENTIALS_TOKEN = { 121 | "jti": "284a265a-8ef5-4b70-925d-ac061273eb21", 122 | "sub": "sb-xssectest", 123 | "authorities": [ 124 | "uaa.resource" 125 | ], 126 | "scope": [ 127 | "uaa.resource" 128 | ], 129 | "client_id": "sb-xssectest", 130 | "cid": "sb-xssectest", 131 | "azp": "sb-xssectest", 132 | "grant_type": "client_credentials", 133 | "iat": 1470814482, 134 | "exp": 2101534482, 135 | "iss": "http://saas.localhost:8080/uaa/oauth/token", 136 | "zid": "test-idz", 137 | "aud": [ 138 | "sb-xssectest", 139 | "uaa" 140 | ], 141 | "az_attr": { 142 | "external_group": "domaingroup1", 143 | "external_id": "abcd1234" 144 | }, 145 | "ext_attr": { 146 | "serviceinstanceid": "abcd1234", 147 | "zdn": "saas" 148 | }, 149 | "xs.system.attributes": { 150 | "xs.saml.groups": [ 151 | "Canary_RoleBuilder" 152 | ], 153 | "xs.rolecollections": [] 154 | }, 155 | "xs.user.attributes": { 156 | "country": [ 157 | "USA" 158 | ] 159 | } 160 | } 161 | 162 | CLIENT_CREDENTIALS_TOKEN_NO_ATTR = merge(CLIENT_CREDENTIALS_TOKEN, { 163 | "ext_attr": {}, 164 | "az_attr": None, 165 | "xs.system.attributes": None, 166 | "xs.user.attributes": None 167 | }) 168 | 169 | CLIENT_CREDENTIALS_BROKER_PLAN_TOKEN = merge(CLIENT_CREDENTIALS_TOKEN, { 170 | "client_id": "sb-xssectestclone!b4|sb-xssectest!b4", 171 | "cid": "sb-xssectestclone!b4|sb-xssectest!b4", 172 | "ext_attr": {} 173 | }) 174 | 175 | CLIENT_CREDENTIALS_TOKEN_ATTR_SUBACCOUNTID = merge(CLIENT_CREDENTIALS_TOKEN, { 176 | "ext_attr": { 177 | "serviceinstanceid": "abcd1234", 178 | "zdn": "saas", 179 | "subaccountid": "5432", 180 | }, 181 | }) 182 | 183 | TOKEN_NEW_FORMAT = { 184 | "jti": "6c0072fd01fb440b86f8a23bf91612b4-r", 185 | "sub": "b5607c1e-5494-4bf3-8305-de35357e0021", 186 | "scope": [ 187 | "openid" 188 | ], 189 | "ext_attr": { 190 | "enhancer": "XSUAA", 191 | "given_name": "michi", 192 | "family_name": "engler", 193 | "serviceinstanceid": "reuse-service-paas-lr-clone2-instanceid" 194 | }, 195 | "ext_cxt": { 196 | "hdb.nameduser.saml": "local-idp+Y1GYfmqS5JPVIVXQSWBx5++6ec=dGlm4QwVGz+NzI9ufKdav6bDoV6BLU+EOEQXZGRbpsr+KyzMjNGcutq3Dcmoh9KOk+wxqE0uWwypQfd1YLV0f2LuQAFo5zNH0uqxsqtkq4YhPNvt0q85vupa/FacyBIjJsKXTnh0OrMS7aDu/j4Tk4J7bk964/B4fzVlanPxBulh/alcA3FnDpAOeSwlr9iTqj22l9LSHuglF7wFhfcZCT+emUbJR9RL9uy4DKzI+pM/q8blPfmirrWWKtiEFsqxgRWFjJTMM9vwFodUlZBnxoQYqRHaW3Nfsnwcl+642lSxMyRAckbYlO2DXL8QsJZxOAXC87Mrkh4ltphtkwYmDA==TestUserurn:oasis:names:tc:SAML:2.0:ac:classes:Passwordg1urn:oasis:names:tc:SAML:2.0:ac:classes:Password", 197 | "xs.user.attributes": { 198 | "country": [ 199 | "de" 200 | ] 201 | }, 202 | "xs.system.attributes": { 203 | "xs.saml.groups": [ 204 | "g1" 205 | ], 206 | "xs.rolecollections": [] 207 | } 208 | }, 209 | "iat": 1510830717, 210 | "exp": 2177366400, 211 | "cid": "sb-clone2!b1|LR-master!b1", 212 | "client_id": "sb-clone2!b1|LR-master!b1", 213 | "iss": "http://paas.localhost:8080/uaa/oauth/token", 214 | "zid": "paas", 215 | "revocable": True, 216 | "grant_type": "user_token", 217 | "user_name": "TestUser", 218 | "origin": "useridp", 219 | "user_id": "b5607c1e-5494-4bf3-8305-de35357e0021", 220 | "rev_sig": "f2b8ade8", 221 | "aud": [] 222 | } 223 | 224 | TOKEN_XSA_FORMAT = { 225 | "sub": "HDB00", 226 | "name": "SYSTEM", 227 | "cid": "sb-xssectest", 228 | "zid": "uaa", 229 | "admin": True, 230 | "authorities": [ 231 | "uaa.resource" 232 | ], 233 | "scope": [ 234 | "uaa.user", 235 | "openid", 236 | "uaa.resource" 237 | ], 238 | "user_name": "ADMIN" 239 | } 240 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019-2021 SAP SE or an SAP affiliate company and cloud-pysec contributors 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/test_xssec.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,invalid-name,missing-docstring,too-many-public-methods 2 | from ssl import SSLError 3 | import unittest 4 | import json 5 | from os import environ 6 | from datetime import datetime 7 | from sap import xssec 8 | from sap.xssec import constants, jwt_validation_facade, security_context_xsuaa 9 | from tests import uaa_configs 10 | from tests import jwt_payloads 11 | from tests.http_responses import HTTP_SUCCESS 12 | from tests.jwt_tools import sign 13 | 14 | 15 | try: 16 | from importlib import reload 17 | from unittest.mock import MagicMock, patch 18 | except ImportError: 19 | reload = None 20 | from mock import MagicMock, patch 21 | 22 | CONFIG_ERROR_MSG = 'Either clientid,clientsecret,url or clientid,certificate,certurl should be provided' 23 | 24 | 25 | class XSSECTest(unittest.TestCase): 26 | 27 | def setUp(self): 28 | reload(jwt_validation_facade) 29 | reload(security_context_xsuaa) 30 | jwt_validation_facade.ALGORITHMS = ['RS256', 'HS256'] 31 | 32 | patcher = patch('httpx.get') 33 | self.mock_httpx_get = patcher.start() 34 | self.addCleanup(patcher.stop) 35 | self.mock_httpx_get.side_effect = SSLError 36 | 37 | def _check_invalid_params(self, token, uaa, message): 38 | with self.assertRaises(ValueError) as ctx: 39 | xssec.create_security_context(token, uaa) 40 | self.assertEqual(message, str(ctx.exception)) 41 | 42 | def test_input_validation_none_token(self): 43 | ''' input validation: None token ''' 44 | self._check_invalid_params( 45 | None, uaa_configs.VALID['uaa'], '"token" should not be None') 46 | 47 | def test_input_validation_empty_token(self): 48 | ''' input validation: empty token ''' 49 | self._check_invalid_params( 50 | '', uaa_configs.VALID['uaa'], '"token" should not be an empty string') 51 | 52 | def test_input_validation_invalid_token(self): 53 | ''' input validation: invalid token ''' 54 | with self.assertRaises(ValueError) as ctx: 55 | xssec.create_security_context('invalid', uaa_configs.VALID['uaa']) 56 | self.assertEqual( 57 | 'Failed to decode provided token', 58 | str(ctx.exception)) 59 | 60 | def test_input_validation_none_config(self): 61 | ''' input validation: None config ''' 62 | self._check_invalid_params( 63 | 'valid', None, '"config" should not be None') 64 | 65 | def test_input_validation_invalid_config_url(self): 66 | ''' input validation: invalid config url ''' 67 | self._check_invalid_params( 68 | 'valid', uaa_configs.INVALID['uaa_url_undefined'], CONFIG_ERROR_MSG) 69 | 70 | def test_input_validation_invalid_config_clientid(self): 71 | ''' input validation: invalid config clientid ''' 72 | self._check_invalid_params( 73 | 'valid', 74 | uaa_configs.INVALID['uaa_clientid_undefined'], 75 | CONFIG_ERROR_MSG) 76 | 77 | def test_input_validation_invalid_config_clientsecretand_and_certificate(self): 78 | ''' input validation: invalid config clientsecret ''' 79 | self._check_invalid_params( 80 | 'valid', 81 | uaa_configs.INVALID['uaa_clientsecret_and_certificate_undefined'], 82 | CONFIG_ERROR_MSG) 83 | 84 | def test_input_validation_invalid_config_xsappname(self): 85 | ''' input validation: invalid config xsappname ''' 86 | self._check_invalid_params( 87 | 'valid', 88 | uaa_configs.INVALID['uaa_xsappname_undefined'], 89 | 'Invalid config: Missing xsappname.' 90 | ' The application name needs to be defined in xs-security.json.') 91 | 92 | def _check_user_info(self, sec_context): 93 | self.assertEqual(sec_context.get_logon_name(), 'NODETESTUSER') 94 | self.assertEqual(sec_context.get_given_name(), 'NodetestFirstName') 95 | self.assertEqual(sec_context.get_family_name(), 'NodetestLastName') 96 | self.assertEqual(sec_context.get_email(), 'Nodetest@sap.com') 97 | 98 | def _check_hdb_token(self, sec_context): 99 | hdb_token = sec_context.get_hdb_token() 100 | self.assertIsNotNone(hdb_token) 101 | system_hdb_token = sec_context.get_token( 102 | xssec.constants.SYSTEM, xssec.constants.HDB) 103 | self.assertEqual(system_hdb_token, hdb_token) 104 | 105 | def _check_app_token(self, sec_context): 106 | app_token = sec_context.get_app_token() 107 | self.assertIsNotNone(app_token) 108 | system_app_token = sec_context.get_token( 109 | xssec.constants.SYSTEM, xssec.constants.JOBSCHEDULER) 110 | self.assertEqual(system_app_token, app_token) 111 | 112 | def _check_user_token(self, sec_context): 113 | self.assertTrue(sec_context.check_scope('openid')) 114 | self.assertTrue(sec_context.check_scope('$XSAPPNAME.resource')) 115 | self.assertFalse(sec_context.check_scope( 116 | 'cloud_controller.nonexistingscope')) 117 | self.assertTrue(sec_context.check_local_scope('resource')) 118 | self.assertFalse(sec_context.check_local_scope('nonexistingscope')) 119 | self._check_user_info(sec_context) 120 | self._check_hdb_token(sec_context) 121 | self.assertIsNone(sec_context.get_attribute('hugo')) 122 | self.assertIsNone(sec_context.get_additional_auth_attribute('hugo')) 123 | self.assertEqual(sec_context.get_grant_type(), 124 | xssec.constants.GRANTTYPE_PASSWORD) 125 | self.assertEqual(sec_context.get_identity_zone(), 'test-idz') 126 | self.assertEqual(sec_context.get_zone_id(), 'test-idz') 127 | self.assertEqual(sec_context.get_subaccount_id(), 'test-idz') 128 | self.assertEqual(sec_context.get_origin(), 'testidp') 129 | self.assertEqual(sec_context.get_subdomain(), 'paas') 130 | self.assertFalse(sec_context.is_in_foreign_mode()) 131 | self.assertEqual(sec_context.get_expiration_date(), 132 | datetime.utcfromtimestamp(2101535434)) 133 | 134 | def test_valid_end_user_token_with_attr(self): 135 | ''' Test valid end-user token with attributes ''' 136 | sec_context = xssec.create_security_context( 137 | sign(jwt_payloads.USER_TOKEN), uaa_configs.VALID['uaa']) 138 | self._check_user_token(sec_context) 139 | self.assertTrue(sec_context.has_attributes()) 140 | self.assertEqual(sec_context.get_attribute('country'), ['USA']) 141 | self.assertEqual( 142 | sec_context.get_clone_service_instance_id(), 'abcd1234') 143 | self.assertEqual( 144 | sec_context.get_additional_auth_attribute('external_group'), 'domaingroup1') 145 | 146 | def test_valid_end_user_token_no_attr(self): 147 | ''' Test valid end-user token no attributes ''' 148 | sec_context = xssec.create_security_context( 149 | sign(jwt_payloads.USER_TOKEN_NO_ATTR), uaa_configs.VALID['uaa']) 150 | self._check_user_token(sec_context) 151 | self.assertFalse(sec_context.has_attributes()) 152 | self.assertIsNone(sec_context.get_clone_service_instance_id()) 153 | 154 | def test_valid_end_user_token_with_ext_attr(self): 155 | ''' Test valid end-user token (given_name/family_name in ext_attr) ''' 156 | sec_context = xssec.create_security_context( 157 | sign(jwt_payloads.USER_TOKEN_NAMES_IN_EXT_ATTR), uaa_configs.VALID['uaa']) 158 | self.assertEqual( 159 | sec_context.get_given_name(), 'NodetestFirstNameExtAttr') 160 | self.assertEqual( 161 | sec_context.get_family_name(), 'NodetestLastNameExtAttr') 162 | 163 | def test_expired_end_user_token(self): 164 | ''' Test expired end-user token ''' 165 | with self.assertRaises(RuntimeError) as ctx: 166 | xssec.create_security_context( 167 | sign(jwt_payloads.USER_TOKEN_EXPIRED), uaa_configs.VALID['uaa']) 168 | self.assertTrue( 169 | 'Error in offline validation of access token:' in str(ctx.exception) and 170 | 'expired' in str(ctx.exception) 171 | ) 172 | 173 | def test_invalid_signature_end_user_token(self): 174 | ''' Test invalid signature end-user token ''' 175 | token_parts = sign(jwt_payloads.USER_TOKEN).split('.') 176 | token_parts[2] = 'aW52YWxpZAo' 177 | invalid_token = '.'.join(token_parts) 178 | with self.assertRaises(RuntimeError) as ctx: 179 | xssec.create_security_context( 180 | invalid_token, uaa_configs.VALID['uaa']) 181 | self.assertTrue( 182 | 'Error in offline validation of access token:' in str(ctx.exception)) 183 | 184 | # def test_valid_end_user_token_in_foreign_mode_idz(self): 185 | # ''' valid end-user token in foreign mode (idz - correct SAP_JWT_TRUST_ACL) ''' 186 | # environ['SAP_JWT_TRUST_ACL'] = '[{"clientid":"sb-xssectest","identityzone":"test-idz"}]' 187 | # sec_context = xssec.create_security_context( 188 | # sign(jwt_payloads.USER_TOKEN), uaa_configs.VALID['uaa_foreign_idz']) 189 | # self.assertTrue(sec_context.is_in_foreign_mode()) 190 | # self.assertEqual( 191 | # sec_context.get_additional_auth_attribute('external_group'), 'domaingroup1') 192 | # self.assertIsNone(sec_context.get_additional_auth_attribute('hugo')) 193 | # self.assertIsNone(sec_context.get_hdb_token()) 194 | # self.assertIsNotNone(sec_context.get_app_token()) 195 | 196 | def _check_token_in_foreign_mode(self, cid, idz, uaa_config_name): 197 | environ['SAP_JWT_TRUST_ACL'] = json.dumps([{ 198 | 'clientid': 'other-clientid', 199 | 'identityzone': 'other-idz' 200 | }, 201 | { 202 | 'clientid': cid, 203 | 'identityzone': idz 204 | }]) 205 | sec_context = xssec.create_security_context( 206 | sign(jwt_payloads.USER_TOKEN_NO_ATTR), uaa_configs.VALID[uaa_config_name]) 207 | self.assertTrue(sec_context.is_in_foreign_mode()) 208 | self.assertIsNotNone(sec_context.get_hdb_token()) 209 | self.assertIsNotNone(sec_context.get_app_token()) 210 | 211 | # TBD :After foriegn mode decision is made 212 | # def test_valid_end_user_token_in_foreign_mode_clientid(self): 213 | # ''' valid end-user token in foreign mode (clientid - correct SAP_JWT_TRUST_ACL) ''' 214 | # self._check_token_in_foreign_mode( 215 | # 'sb-xssectest', 'test-idz', 'uaa_foreign_clientid') 216 | 217 | # TBD :After foriegn mode decision is made 218 | # def test_valid_end_user_token_in_foreign_mode_idz_and_clientid(self): 219 | # ''' valid end-user token in foreign mode (idz & clientid - correct SAP_JWT_TRUST_ACL) ''' 220 | # self._check_token_in_foreign_mode( 221 | # 'sb-xssectest', 'test-idz', 'uaa_foreign_idz_clientid') 222 | 223 | # TBD :After foriegn mode decision is made 224 | # def test_valid_end_user_token_in_foreign_mode_idz_and_clientid_with_star(self): 225 | # ''' valid end-user token in foreign mode (idz & clientid in SAP_JWT_TRUST_ACL with *) ''' 226 | # self._check_token_in_foreign_mode('*', '*', 'uaa_foreign_idz_clientid') 227 | 228 | def _check_token_in_foreign_mode_error(self, cid, idz, uaa_config_name): 229 | environ['SAP_JWT_TRUST_ACL'] = json.dumps([{ 230 | 'clientid': cid, 231 | 'identityzone': idz 232 | }]) 233 | with self.assertRaises(RuntimeError) as ctx: 234 | xssec.create_security_context( 235 | sign(jwt_payloads.USER_TOKEN_NO_ATTR), uaa_configs.VALID[uaa_config_name]) 236 | self.assertTrue(str(ctx.exception).startswith( 237 | 'No match found in JWT trust ACL (SAP_JWT_TRUST_ACL)')) 238 | 239 | # def test_valid_end_user_token_in_foreign_mode_invalid_idz(self): 240 | # ''' valid end-user token in foreign mode (idz - incorrect SAP_JWT_TRUST_ACL) ''' 241 | # self._check_token_in_foreign_mode_error( 242 | # 'sb-xssectest', 'uaa', 'uaa_foreign_idz') 243 | 244 | # def test_valid_end_user_token_in_foreign_mode_invalid_clientid(self): 245 | # ''' valid end-user token in foreign mode (clientid - incorrect SAP_JWT_TRUST_ACL) ''' 246 | # self._check_token_in_foreign_mode_error( 247 | # 'foreign-clientid', 'test-idz', 'uaa_foreign_clientid') 248 | 249 | def test_valid_end_user_saml_bearer_token(self): 250 | ''' valid end-user saml bearer token ''' 251 | sec_context = xssec.create_security_context( 252 | sign(jwt_payloads.USER_SAML_BEARER_TOKEN), uaa_configs.VALID['uaa_bearer']) 253 | self.assertTrue(sec_context.check_scope('openid')) 254 | self._check_user_info(sec_context) 255 | self._check_hdb_token(sec_context) 256 | self.assertEqual(sec_context.get_grant_type(), 257 | xssec.constants.GRANTTYPE_SAML2BEARER) 258 | self.assertEqual(sec_context.get_identity_zone(), 'test-idz') 259 | self.assertEqual(sec_context.get_zone_id(), 'test-idz') 260 | self.assertEqual(sec_context.get_subaccount_id(), 'test-idz') 261 | self.assertIsNone(sec_context.get_subdomain()) 262 | self.assertFalse(sec_context.is_in_foreign_mode()) 263 | 264 | def test_valid_end_user_application_plan_token(self): 265 | ''' valid end-user application plan token ''' 266 | sec_context = xssec.create_security_context( 267 | sign(jwt_payloads.USER_APPLICATION_PLAN_TOKEN), 268 | uaa_configs.VALID['uaa_application_plan']) 269 | 270 | self.assertTrue(sec_context.check_scope('openid')) 271 | self.assertTrue(sec_context.check_scope('$XSAPPNAME.resource')) 272 | self.assertFalse(sec_context.check_scope( 273 | 'cloud_controller.nonexistingscope')) 274 | self.assertTrue(sec_context.check_local_scope('resource')) 275 | self.assertFalse(sec_context.check_local_scope('nonexistingscope')) 276 | self._check_user_info(sec_context) 277 | self._check_hdb_token(sec_context) 278 | self.assertIsNone(sec_context.get_attribute('hugo')) 279 | self.assertIsNone(sec_context.get_additional_auth_attribute('hugo')) 280 | self.assertEqual(sec_context.get_grant_type(), 281 | xssec.constants.GRANTTYPE_PASSWORD) 282 | self.assertEqual(sec_context.get_identity_zone(), 'test-idz') 283 | self.assertEqual(sec_context.get_zone_id(), 'test-idz') 284 | self.assertEqual(sec_context.get_subaccount_id(), 'test-idz') 285 | self.assertIsNone(sec_context.get_subdomain()) 286 | self.assertFalse(sec_context.is_in_foreign_mode()) 287 | 288 | def _check_client_credentials_token(self, sec_context, expected_subaccount_id='test-idz'): 289 | self.assertTrue(sec_context.check_scope('$XSAPPNAME.resource')) 290 | self.assertTrue(sec_context.check_scope('uaa.resource')) 291 | self.assertFalse(sec_context.check_scope( 292 | 'cloud_controller.nonexistingscope')) 293 | self.assertTrue(sec_context.check_local_scope('resource')) 294 | self.assertFalse(sec_context.check_local_scope('nonexistingscope')) 295 | self._check_hdb_token(sec_context) 296 | user_attribute_getters = [ 297 | 'get_logon_name', 'get_family_name', 'get_given_name', 'get_email', 'has_attributes' 298 | ] 299 | for getter in user_attribute_getters: 300 | self.assertIsNone(getattr(sec_context, getter)()) 301 | self.assertIsNone(sec_context.get_attribute('country')) 302 | self.assertIsNone(sec_context.get_attribute('hugo')) 303 | self.assertIsNone(sec_context.get_additional_auth_attribute('hugo')) 304 | self.assertEqual( 305 | sec_context.get_grant_type(), xssec.constants.GRANTTYPE_CLIENTCREDENTIAL) 306 | self.assertEqual(sec_context.get_identity_zone(), 'test-idz') 307 | self.assertEqual(sec_context.get_zone_id(), 'test-idz') 308 | self.assertEqual(sec_context.get_subaccount_id(), expected_subaccount_id) 309 | self.assertIsNone(sec_context.get_origin()) 310 | self.assertEqual(sec_context.get_clientid(), 'sb-xssectest') 311 | self.assertFalse(sec_context.is_in_foreign_mode()) 312 | self.assertEqual(sec_context.get_expiration_date(), 313 | datetime.utcfromtimestamp(2101534482)) 314 | 315 | def test_valid_client_credentials_token_attributes(self): 316 | ''' valid client credentials token (with attributes) ''' 317 | sec_context = xssec.create_security_context( 318 | sign(jwt_payloads.CLIENT_CREDENTIALS_TOKEN), 319 | uaa_configs.VALID['uaa_cc']) 320 | self._check_client_credentials_token(sec_context) 321 | self.assertEqual( 322 | sec_context.get_additional_auth_attribute('external_group'), 'domaingroup1') 323 | self.assertEqual( 324 | sec_context.get_clone_service_instance_id(), 'abcd1234') 325 | 326 | def test_valid_client_credentials_token_no_attributes(self): 327 | ''' valid client credentials token (no attributes) ''' 328 | sec_context = xssec.create_security_context( 329 | sign(jwt_payloads.CLIENT_CREDENTIALS_TOKEN_NO_ATTR), 330 | uaa_configs.VALID['uaa_cc']) 331 | self._check_client_credentials_token(sec_context) 332 | self.assertIsNone( 333 | sec_context.get_additional_auth_attribute('external_group')) 334 | 335 | def test_valid_credentials_token_subaccount(self): 336 | ''' valid client credentials token (subaccountid in attributes) ''' 337 | sec_context = xssec.create_security_context( 338 | sign(jwt_payloads.CLIENT_CREDENTIALS_TOKEN_ATTR_SUBACCOUNTID), 339 | uaa_configs.VALID['uaa_cc']) 340 | # if subaccountid is set, then the "subaccount_id" property is taken 341 | # from subaccountid and no longer from the zid field 342 | self._check_client_credentials_token(sec_context, expected_subaccount_id='5432') 343 | 344 | def _check_client_credentials_broker_plan(self): 345 | sec_context = xssec.create_security_context( 346 | sign(jwt_payloads.CLIENT_CREDENTIALS_BROKER_PLAN_TOKEN), 347 | uaa_configs.VALID['uaa_broker_plan']) 348 | self.assertTrue(sec_context.check_scope('$XSAPPNAME.resource')) 349 | self.assertTrue(sec_context.check_scope('uaa.resource')) 350 | self._check_hdb_token(sec_context) 351 | self.assertIsNone(sec_context.has_attributes()) 352 | self.assertIsNone(sec_context.get_attribute('country')) 353 | self.assertEqual(sec_context.get_grant_type(), 354 | xssec.constants.GRANTTYPE_CLIENTCREDENTIAL) 355 | self.assertEqual(sec_context.get_identity_zone(), 'test-idz') 356 | self.assertEqual(sec_context.get_zone_id(), 'test-idz') 357 | self.assertEqual(sec_context.get_subaccount_id(), 'test-idz') 358 | self.assertEqual(sec_context.get_clientid(), 359 | 'sb-xssectestclone!b4|sb-xssectest!b4') 360 | self.assertIsNone(sec_context.get_subdomain()) 361 | self.assertFalse(sec_context.is_in_foreign_mode()) 362 | 363 | def test_valid_client_credentials_broker_plan_token_acl_not_matching(self): 364 | ''' valid client credentials broker plan token with SAP_JWT_TRUST_ACL (not matching) ''' 365 | environ['SAP_JWT_TRUST_ACL'] = json.dumps([{ 366 | 'clientid': 'hugo', 367 | 'identityzone': 'uaa' 368 | }]) 369 | self._check_client_credentials_broker_plan() 370 | 371 | def test_valid_client_credentials_broker_plan_token_no_acl(self): 372 | ''' valid client credentials broker plan token without SAP_JWT_TRUST_ACL ''' 373 | self._check_client_credentials_broker_plan() 374 | 375 | # def test_valid_client_credentials_broker_plan_token_with_wrong_trustedclientidsuffix(self): 376 | # ''' valid client credentials broker plan token with wrong trustedclientidsuffix ''' 377 | # with self.assertRaises(RuntimeError) as ctx: 378 | # xssec.create_security_context( 379 | # sign(jwt_payloads.CLIENT_CREDENTIALS_BROKER_PLAN_TOKEN), 380 | # uaa_configs.INVALID['uaa_broker_plan_wrong_suffix']) 381 | # self.assertEqual( 382 | # 'Missmatch of client id and/or identityzone id. No JWT trust ACL (SAP_JWT_TRUST_ACL) specified in environment. ' 383 | # 'Client id of the access token: "sb-xssectestclone!b4|sb-xssectest!b4", identity zone of the access token: ' 384 | # '"test-idz", OAuth client id: "sb-xssectest!t4", application identity zone: "test-idz".' 385 | # , str(ctx.exception)) 386 | 387 | def test_valid_application_plan_with_trustedclientidsuffix(self): 388 | ''' valid application plan with shared tenant mode, defined via SAP_JWT_TRUST_ACL ''' 389 | environ['SAP_JWT_TRUST_ACL'] = json.dumps([{ 390 | 'clientid': '*', 391 | 'identityzone': '*' 392 | }]) 393 | sec_context = xssec.create_security_context( 394 | sign(jwt_payloads.INVALID_TRUSTED_APPLICATION_PLAN_TOKEN), 395 | uaa_configs.INVALID['uaa_broker_plan_wrong_suffix']) 396 | self.assertEqual('sb-tenant-test!t13',sec_context.get_clientid()) 397 | self.assertEqual('api', sec_context.get_identity_zone()) 398 | self.assertEqual('api', sec_context.get_zone_id()) 399 | 400 | # def test_invalid_application_plan_with_trustedclientidsuffix(self): 401 | # ''' invalid application plan with SAP_JWT_TRUST_ACL ''' 402 | # environ['SAP_JWT_TRUST_ACL'] = json.dumps([{ 403 | # 'clientid': 'wrong-tenant', 404 | # 'identityzone': 'api' 405 | # }]) 406 | # with self.assertRaises(RuntimeError) as ctx: 407 | # xssec.create_security_context( 408 | # sign(jwt_payloads.INVALID_TRUSTED_APPLICATION_PLAN_TOKEN), 409 | # uaa_configs.INVALID['uaa_broker_plan_wrong_suffix']) 410 | # self.assertTrue(str(ctx.exception).startswith( 411 | # 'No match found in JWT trust ACL (SAP_JWT_TRUST_ACL)')) 412 | 413 | def test_token_with_ext_cxt(self): 414 | ''' valid user token with "ext_cxt" property ''' 415 | sec_context = xssec.create_security_context( 416 | sign(jwt_payloads.TOKEN_NEW_FORMAT), 417 | uaa_configs.VALID['uaa_new_token_structure']) 418 | self._check_hdb_token(sec_context) 419 | jobsheduler_token = sec_context.get_token( 420 | xssec.constants.SYSTEM, xssec.constants.JOBSCHEDULER) 421 | self.assertEqual(jobsheduler_token, sign(jwt_payloads.TOKEN_NEW_FORMAT)) 422 | self.assertNotEqual(sec_context.get_hdb_token(), jobsheduler_token) 423 | 424 | def test_get_token_with_invalid_parameters(self): 425 | ''' valid user token with "ext_cxt" property ''' 426 | sec_context = xssec.create_security_context( 427 | sign(jwt_payloads.TOKEN_NEW_FORMAT), 428 | uaa_configs.VALID['uaa_new_token_structure']) 429 | self._check_hdb_token(sec_context) 430 | self.assertIsNone(sec_context.get_token('invalid', xssec.constants.JOBSCHEDULER)) 431 | self.assertIsNone(sec_context.get_token(xssec.constants.SYSTEM, 'invalid')) 432 | 433 | def test_token_with_ext_cxt_invalid_validation_key(self): 434 | ''' valid user token with "ext_cxt" property, invalid validation key ''' 435 | with self.assertRaises(RuntimeError) as ctx: 436 | xssec.create_security_context( 437 | sign(jwt_payloads.TOKEN_NEW_FORMAT), 438 | uaa_configs.INVALID['uaa_verificationkey_invalid']) 439 | self.assertTrue( 440 | 'Error in offline validation of access token:' in str(ctx.exception)) 441 | 442 | @patch('httpx.get') 443 | def test_get_verification_key_from_uaa(self, mock_requests): 444 | from sap.xssec.key_cache import KeyCache 445 | xssec.SecurityContextXSUAA.verificationKeyCache = KeyCache() 446 | 447 | mock = MagicMock() 448 | mock_requests.return_value = mock 449 | mock.json.return_value = HTTP_SUCCESS 450 | 451 | sec_context = xssec.create_security_context( 452 | sign(jwt_payloads.USER_TOKEN), uaa_configs.VALID['uaa_no_verification_key']) 453 | self._check_user_token(sec_context) 454 | self.assertTrue(sec_context.has_attributes()) 455 | self.assertEqual(sec_context.get_attribute('country'), ['USA']) 456 | self.assertEqual( 457 | sec_context.get_clone_service_instance_id(), 'abcd1234') 458 | self.assertEqual( 459 | sec_context.get_additional_auth_attribute('external_group'), 'domaingroup1') 460 | mock_requests.assert_called_once_with("https://api.cf.test.com/token_keys?zid=test-idz", 461 | timeout=constants.HTTP_TIMEOUT_IN_SECONDS) 462 | 463 | @patch('httpx.get') 464 | def test_composed_jku_with_uaadomain(self, mock_requests): 465 | from sap.xssec.key_cache import KeyCache 466 | xssec.SecurityContextXSUAA.verificationKeyCache = KeyCache() 467 | 468 | mock = MagicMock() 469 | mock_requests.return_value = mock 470 | mock.json.return_value = HTTP_SUCCESS 471 | 472 | xssec.create_security_context( 473 | sign(jwt_payloads.USER_TOKEN), uaa_configs.VALID['uaa_no_verification_key_other_domain']) 474 | mock_requests.assert_called_once_with("https://api.cf2.test.com/token_keys?zid=test-idz", 475 | timeout=constants.HTTP_TIMEOUT_IN_SECONDS) 476 | 477 | def test_valid_xsa_token_attributes(self): 478 | ''' valid client credentials token (with attributes) ''' 479 | sec_context = xssec.create_security_context( 480 | sign(jwt_payloads.TOKEN_XSA_FORMAT), 481 | uaa_configs.VALID['uaa_xsa_environment']) 482 | self.assertEqual( 483 | sec_context.get_logon_name(), 'ADMIN') 484 | 485 | def test_valid_xsa_token_with_newlines(self): 486 | ''' valid client credentials token (with attributes) ''' 487 | sec_context = xssec.create_security_context( 488 | sign(jwt_payloads.TOKEN_XSA_FORMAT), 489 | uaa_configs.VALID['uaa_xsa_with_newlines']) 490 | self.assertEqual( 491 | sec_context.get_logon_name(), 'ADMIN') 492 | 493 | @patch('httpx.get') 494 | def test_ignored_invalid_jku_in_token_header(self, mock_requests): 495 | from sap.xssec.key_cache import KeyCache 496 | xssec.SecurityContextXSUAA.verificationKeyCache = KeyCache() 497 | 498 | uaa_config = uaa_configs.VALID['uaa'] 499 | token = sign(jwt_payloads.USER_TOKEN, headers={ 500 | "jku": 'http://ana.ondemandh.com\\\\\\\\\\\\\\\\@' + uaa_config['uaadomain'], 501 | "kid": "key-id-0" 502 | }) 503 | mock = MagicMock() 504 | mock_requests.return_value = mock 505 | mock.json.return_value = HTTP_SUCCESS 506 | 507 | xssec.create_security_context(token, uaa_config) 508 | mock_requests.assert_called_once_with("https://api.cf.test.com/token_keys?zid=test-idz", 509 | timeout=constants.HTTP_TIMEOUT_IN_SECONDS) 510 | -------------------------------------------------------------------------------- /sap/xssec/security_context_xsuaa.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=too-many-public-methods 2 | """ Security Context class """ 3 | import functools 4 | import re 5 | import tempfile 6 | from os import environ, unlink 7 | import json 8 | from datetime import datetime 9 | import logging 10 | from typing import Any, Dict 11 | 12 | import httpx 13 | import deprecation 14 | 15 | from sap.xssec import constants 16 | from sap.xssec.jwt_validation_facade import JwtValidationFacade, DecodeError 17 | from sap.xssec.key_cache import KeyCache 18 | from sap.xssec.jwt_audience_validator import JwtAudienceValidator 19 | 20 | _client_secret_props = ('clientid', 'clientsecret', 'url') # props for client secret authentication 21 | _client_certificate_props = ("clientid", "certificate", "certurl") # props for client certificate authentication 22 | 23 | 24 | def _check_if_valid(item, name): 25 | if item is None: 26 | raise ValueError('"{0}" should not be None'.format(name)) 27 | if isinstance(item, str) and len(item) < 1: 28 | raise ValueError('"{0}" should not be an empty string'.format(name)) 29 | 30 | 31 | def _check_config(config): 32 | _check_if_valid(config, 'config') 33 | if not _has_client_secret_props(config) and not _has_client_certificate_props(config): 34 | raise ValueError('Either {} or {} should be provided'. 35 | format(','.join(_client_secret_props), ','.join(_client_certificate_props))) 36 | 37 | 38 | def _has_client_secret_props(config): 39 | return all(map(functools.partial(_check_prop, config), _client_secret_props)) 40 | 41 | 42 | def _has_client_certificate_props(config): 43 | return all(map(functools.partial(_check_prop, config), _client_certificate_props)) 44 | 45 | 46 | def _check_prop(config: Dict[str, Any], prop: str) -> bool: 47 | item = None 48 | if prop in config: 49 | item = config[prop] 50 | try: 51 | _check_if_valid(item, 'config.{0}'.format(prop)) 52 | return True 53 | except ValueError: 54 | return False 55 | 56 | 57 | def _create_cert_and_key_files(cert_content, key_content): 58 | cert_file = tempfile.NamedTemporaryFile(mode='w', delete=False) 59 | cert_file.write(cert_content) 60 | cert_file.close() 61 | key_file = None 62 | if key_content is not None: 63 | key_file = tempfile.NamedTemporaryFile(mode='w', delete=False) 64 | key_file.write(key_content) 65 | key_file.close() 66 | return cert_file.name, key_file.name if key_file else None 67 | 68 | 69 | def _delete_cert_and_key_files(cert_file_name, key_file_name): 70 | if cert_file_name: 71 | unlink(cert_file_name) 72 | if key_file_name: 73 | unlink(key_file_name) 74 | 75 | 76 | class SecurityContextXSUAA(object): 77 | """ SecurityContext class """ 78 | 79 | verificationKeyCache = KeyCache() 80 | 81 | def __init__(self, token, config): 82 | _check_if_valid(token, 'token') 83 | self._token = token 84 | _check_config(config) 85 | self._config = config 86 | self._jwt_validator = JwtValidationFacade() 87 | self._logger = logging.getLogger(__name__) 88 | self._properties = {} 89 | self._init_properties() 90 | 91 | def _init_properties(self): 92 | self._init_xsappname() 93 | self._set_properties_defaults() 94 | self._set_token_properties() 95 | self._offline_validation() 96 | self._audience_validation() 97 | 98 | def _init_xsappname(self): 99 | if 'xsappname' not in self._config: 100 | if 'XSAPPNAME' not in environ: 101 | raise ValueError('Invalid config: Missing xsappname.' 102 | ' The application name needs to be defined in xs-security.json.') 103 | else: 104 | self._logger.warning('XSAPPNAME defined in manifest.yml (legacy).' 105 | ' You should switch to defining xsappname' 106 | ' in xs-security.json.') 107 | self._properties['xsappname'] = environ['XSAPPNAME'] 108 | else: 109 | self._properties['xsappname'] = self._config['xsappname'] 110 | if 'XSAPPNAME' in environ: 111 | if self._config['xsappname'] == environ['XSAPPNAME']: 112 | self._logger.warning('The application name is defined both in the manifest.yml' 113 | ' (legacy) as well as in xs-security.json.' 114 | ' Remove it in manifest.yml.') 115 | else: 116 | raise ValueError('Invalid config: Ambiguous xsappname.' 117 | ' The application name is defined with different values in' 118 | ' the manifest.yml (legacy) as well as in xs-security.json.' 119 | ' Remove it in manifest.yml.') 120 | 121 | def _get_jku(self): 122 | uaa_domain: str = self._config.get('uaadomain') or self._config.get('url') 123 | if not uaa_domain: 124 | raise RuntimeError("Service is not properly configured in 'VCAP_SERVICES'") 125 | uaa_domain = re.sub(r'^https://', '', uaa_domain) 126 | payload = self._jwt_validator.decode(self._token, verify=False) 127 | zid = payload.get("zid") 128 | return f"https://{uaa_domain}/token_keys?zid={zid}" if zid else f"https://{uaa_domain}/token_keys" 129 | 130 | def _set_token_properties(self): 131 | 132 | def set_property(json_key): 133 | prop = decoded.get(json_key, None) 134 | self._properties[json_key] = prop 135 | 136 | try: 137 | decoded = self._jwt_validator.get_unverified_header(self._token) 138 | except DecodeError: 139 | raise ValueError("Failed to decode provided token") 140 | 141 | set_property("jku") 142 | set_property("kid") 143 | 144 | def _set_properties_defaults(self): 145 | self._properties['is_foreign_mode'] = False 146 | self._properties['user_info'] = { 147 | 'logon_name': None, 148 | 'given_name': None, 149 | 'family_name': None, 150 | 'email': None 151 | } 152 | self._properties['scopes'] = [] 153 | self._properties['saml_token'] = None 154 | self._properties['subdomain'] = None 155 | self._properties['clientid'] = None 156 | self._properties['zone_id'] = None 157 | self._properties['subaccount_id'] = None 158 | self._properties['user_attributes'] = {} 159 | self._properties['additional_auth_attributes'] = {} 160 | self._properties['service_instance_id'] = None 161 | self._properties['grant_type'] = None 162 | self._properties['origin'] = None 163 | self._properties['expiration_date'] = None 164 | self._properties['jku'] = None 165 | self._properties['kid'] = None 166 | self._properties['uaadomain'] = None 167 | # Audience Property added 168 | self._properties['aud'] = None 169 | 170 | 171 | def _get_jwt_payload(self, verification_key): 172 | self._logger.debug('SSO library path: %s, CCL library path: %s', 173 | environ.get('SSOEXT_LIB'), environ.get('SSF_LIB')) 174 | 175 | result_code = self._jwt_validator.loadPEM( 176 | verification_key) 177 | if result_code != 0: 178 | raise RuntimeError( 179 | 'Invalid verification key, result code {0}'.format(result_code)) 180 | 181 | self._jwt_validator.checkToken(self._token) 182 | error_description = self._jwt_validator.getErrorDescription() 183 | if error_description != '': 184 | raise RuntimeError( 185 | 'Error in offline validation of access token: {0}, result code {1}'.format( 186 | error_description, self._jwt_validator.getErrorRC())) 187 | 188 | jwt_payload = self._jwt_validator.getJWPayload() 189 | for id_type in ['cid', 'zid']: 190 | if not id_type in jwt_payload: 191 | raise RuntimeError( 192 | '{0} not contained in access token.'.format(id_type)) 193 | 194 | return jwt_payload 195 | 196 | def _set_foreign_mode(self, jwt_payload): 197 | is_application_plan = '!t' in jwt_payload['cid'] 198 | clientids_match = jwt_payload['cid'] == self._config['clientid'] 199 | zones_match = jwt_payload['zid'] == self._config.get( 200 | 'identityzoneid') or jwt_payload['zid'] == self._config.get('identityzone') 201 | 202 | if clientids_match and (zones_match or is_application_plan): 203 | self._properties['is_foreign_mode'] = False 204 | if not is_application_plan: 205 | self._logger.debug('Client Id and Identity Zone of the access token match' 206 | 'with the current application\'s Client Id and Zone.') 207 | else: 208 | self._logger.debug('Client Id of the access token (XSUAA application plan)' 209 | ' matches with the current application\'s Client Id.') 210 | elif self._config.get('trustedclientidsuffix') and jwt_payload['cid'].endswith(self._config['trustedclientidsuffix']): 211 | self._logger.debug('Token of UAA service plan "broker" received.') 212 | self._logger.debug( 213 | 'Client Id "%s" of the access token allows consumption by' 214 | ' the Client Id "%s" of the current application', 215 | jwt_payload['cid'], self._config['clientid']) 216 | self._properties['is_foreign_mode'] = False 217 | elif 'SAP_JWT_TRUST_ACL' in environ: 218 | self._logger.debug( 219 | 'Client Id "%s" and/or Identity Zone "%s" of the access' 220 | ' token does not match with the Client Id "%s" and Identity' 221 | ' Zone "%s" of the current application. Validating token against' 222 | ' JWT trust ACL (SAP_JWT_TRUST_ACL).', jwt_payload['cid'], 223 | jwt_payload['zid'], self._config['clientid'], 224 | self._config.get('identityzoneid') or self._config.get('identityzone')) 225 | 226 | acl_trust = json.loads(environ['SAP_JWT_TRUST_ACL']) 227 | for acl_entry in acl_trust: 228 | clientid_match = acl_entry['clientid'] in [ 229 | '*', jwt_payload['cid']] 230 | zoneid_match = acl_entry['identityzone'] in [ 231 | '*', jwt_payload['zid']] 232 | if clientid_match and zoneid_match: 233 | self._properties['is_foreign_mode'] = True 234 | self._logger.debug('Foreign token received, but matching entry' 235 | ' in JWT trust ACL (SAP_JWT_TRUST_ACL) found.') 236 | break 237 | if not self._properties['is_foreign_mode']: 238 | raise RuntimeError( 239 | 'No match found in JWT trust ACL (SAP_JWT_TRUST_ACL) {0}'.format( 240 | acl_trust)) 241 | else: 242 | raise RuntimeError( 243 | 'Missmatch of client id and/or identityzone id.' 244 | ' No JWT trust ACL (SAP_JWT_TRUST_ACL) specified in environment.' 245 | ' Client id of the access token: "{0}",' 246 | ' identity zone of the access token: "{1}",' 247 | ' OAuth client id: "{2}",' 248 | ' application identity zone: "{3}".'.format( 249 | jwt_payload['cid'], 250 | jwt_payload['zid'], 251 | self._config['clientid'], 252 | self._config.get('identityzoneid') or self._config.get('identityzone'))) 253 | 254 | def _set_grant_type(self, jwt_payload): 255 | self._properties['grant_type'] = jwt_payload.get('grant_type') 256 | self._logger.debug( 257 | 'Application received a token of grant type "%s".', 258 | self.get_grant_type()) 259 | 260 | def _set_origin(self, jwt_payload): 261 | self._properties['origin'] = jwt_payload.get('origin') 262 | self._logger.debug( 263 | 'Application received a token with user origin "%s".', 264 | self.get_origin()) 265 | 266 | def _set_jwt_expiration(self, jwt_payload): 267 | jwt_expiration = jwt_payload.get('exp') 268 | self._logger.debug( 269 | 'Application received a token with exp: %s', jwt_expiration) 270 | if jwt_expiration: 271 | self._properties['expiration_date'] = datetime.utcfromtimestamp( 272 | jwt_expiration) 273 | 274 | def _set_audience(self, jwt_payload): 275 | self._properties['aud'] = jwt_payload.get('aud') or [] 276 | self._logger.debug( 277 | 'Application received a token with "%s".', 278 | self.get_audience()) 279 | 280 | def _set_user_info(self, jwt_payload): 281 | if self.get_grant_type() == constants.GRANTTYPE_CLIENTCREDENTIAL: 282 | return 283 | user_info = self._properties['user_info'] 284 | user_info['logon_name'] = jwt_payload.get('user_name') 285 | if 'ext_attr' in jwt_payload and 'given_name' in jwt_payload['ext_attr']: 286 | user_info['given_name'] = jwt_payload['ext_attr']['given_name'] 287 | else: 288 | user_info['given_name'] = jwt_payload.get('given_name') 289 | 290 | if 'ext_attr' in jwt_payload and 'family_name' in jwt_payload['ext_attr']: 291 | user_info['family_name'] = jwt_payload['ext_attr']['family_name'] 292 | else: 293 | user_info['family_name'] = jwt_payload.get('family_name') 294 | user_info['email'] = jwt_payload.get('email') 295 | self._logger.debug('User info: %s', user_info) 296 | 297 | ext_cxt_container = jwt_payload # old jwt structure 298 | if 'ext_cxt' in jwt_payload: 299 | ext_cxt_container = jwt_payload['ext_cxt'] # new jwt structure 300 | 301 | self._properties['saml_token'] = ext_cxt_container.get( 302 | 'hdb.nameduser.saml') 303 | user_attributes = ext_cxt_container.get('xs.user.attributes') or {} 304 | self._properties['user_attributes'] = user_attributes 305 | self._logger.debug('Obtained attributes: %s.', user_attributes) 306 | 307 | def _set_additional_auth_attr(self, jwt_payload): 308 | additional_auth_attributes = jwt_payload.get('az_attr') or {} 309 | self._properties['additional_auth_attributes'] = additional_auth_attributes 310 | self._logger.debug('Obtained additional authentication attributes: %s.', 311 | additional_auth_attributes) 312 | 313 | def _set_ext_attr(self, jwt_payload): 314 | ext_attr = jwt_payload.get('ext_attr') 315 | if ext_attr: 316 | self._properties['service_instance_id'] = ext_attr.get('serviceinstanceid') 317 | self._logger.debug('Obtained serviceinstanceid: %s.', self._properties['service_instance_id']) 318 | 319 | self._properties['subdomain'] = ext_attr.get('zdn') 320 | self._logger.debug('Obtained subdomain: %s.', self._properties['subdomain']) 321 | 322 | # set subaccount_id to the zone_id as a workaround, if subaccount id is not available. 323 | if 'subaccountid' in ext_attr and ext_attr.get('subaccountid'): 324 | self._properties['subaccount_id'] = ext_attr.get('subaccountid') 325 | self._logger.debug('Obtained subaccountid: %s.', self._properties['subaccount_id']) 326 | else: 327 | self._properties['subaccount_id'] = jwt_payload['zid'] 328 | self._logger.debug('Subaccountid not found. Using zid instead: %s.', jwt_payload['zid']) 329 | else: 330 | self._properties['subaccount_id'] = jwt_payload['zid'] 331 | self._logger.debug('Subaccountid not found. Using zid instead: %s.', jwt_payload['zid']) 332 | 333 | def _set_scopes(self, jwt_payload): 334 | self._properties['scopes'] = jwt_payload.get('scope') or [] 335 | self._logger.debug('Obtained scopes: %s.', self._properties['scopes']) 336 | 337 | def _validate_token(self): 338 | """ Try to retrieve the key from the composed jku if kid is set. Otherwise use configured one.""" 339 | if self._properties['kid']: 340 | try: 341 | jku = self._get_jku() 342 | verification_key = SecurityContextXSUAA.verificationKeyCache.load_key(jku, self._properties['kid']) 343 | return self._get_jwt_payload(verification_key) 344 | except (DecodeError, RuntimeError, IOError) as e: 345 | self._logger.warning("Warning: Could not validate key: {} Will retry with configured key.".format(e)) 346 | 347 | if "verificationkey" in self._config: 348 | self._logger.debug("Validate token with configured verifcation key") 349 | return self._get_jwt_payload(self._config["verificationkey"]) 350 | else: 351 | raise RuntimeError("Cannot validate token without verificationkey") 352 | 353 | def _offline_validation(self): 354 | jwt_payload = self._validate_token() 355 | # Remove Foreign Mode Support for Now , Support for SAP_JWT_ACL removed 356 | # self._set_foreign_mode(jwt_payload) 357 | self._set_grant_type(jwt_payload) 358 | self._set_origin(jwt_payload) 359 | self._properties['clientid'] = jwt_payload['cid'] 360 | self._properties['zone_id'] = jwt_payload['zid'] 361 | self._set_jwt_expiration(jwt_payload) 362 | self._set_user_info(jwt_payload) 363 | self._set_additional_auth_attr(jwt_payload) 364 | self._set_ext_attr(jwt_payload) 365 | self._set_scopes(jwt_payload) 366 | self._set_audience(jwt_payload) 367 | 368 | def _audience_validation(self): 369 | audience_validator = JwtAudienceValidator(self._config['clientid']) 370 | if(self._config['xsappname']): 371 | audience_validator.configure_trusted_clientId(self._config['xsappname']) 372 | validation_result = audience_validator.validate_token(self.get_clientid(), self.get_audience(), self._properties['scopes']) 373 | 374 | if validation_result is False: 375 | raise RuntimeError('Audience Validation Failed') 376 | 377 | def _get_property_of(self, property_name, obj): 378 | if self.get_grant_type() == constants.GRANTTYPE_CLIENTCREDENTIAL: 379 | self._logger.debug('Cannot get "%s" with a token of grant type %s', 380 | property_name, constants.GRANTTYPE_CLIENTCREDENTIAL) 381 | return None 382 | return obj.get(property_name) 383 | 384 | def _get_user_info_property(self, property_name): 385 | return self._get_property_of(property_name, self._properties['user_info']) 386 | 387 | @deprecation.deprecated(deprecated_in="2.0.11", details="Use the get_zone_id method instead") 388 | def get_identity_zone(self): 389 | """:return: The identity zone. """ 390 | return self._properties['zone_id'] 391 | 392 | def get_zone_id(self): 393 | """:return: The zone id. """ 394 | return self._properties['zone_id'] 395 | 396 | def get_subaccount_id(self): 397 | """:return: The subaccount id""" 398 | return self._properties['subaccount_id'] 399 | 400 | def get_subdomain(self): 401 | """:return: The subdomain that the access token has been issued for. """ 402 | return self._properties['subdomain'] 403 | 404 | def get_clientid(self): 405 | """:return: The client id that the access token has been issued for """ 406 | return self._properties['clientid'] 407 | 408 | def get_expiration_date(self): 409 | """:return: The expiration date of the token. """ 410 | return self._properties['expiration_date'] 411 | 412 | def get_logon_name(self): 413 | """:return: The logon name or None if token is with grant type client credentials. """ 414 | return self._get_user_info_property('logon_name') 415 | 416 | def get_given_name(self): 417 | """:return: The given name or None if token is with grant type client credentials. """ 418 | return self._get_user_info_property('given_name') 419 | 420 | def get_family_name(self): 421 | """:return: The family name or None if token is with grant type client credentials. """ 422 | return self._get_user_info_property('family_name') 423 | 424 | def get_email(self): 425 | """:return: The email or None if token is with grant type client credentials. """ 426 | return self._get_user_info_property('email') 427 | 428 | def get_token(self, namespace, name): 429 | """ 430 | :param namespace: Namespace used for identifying the different use cases. 431 | 432 | :param name: The name used to differentiate between tokens in a given namespace. 433 | 434 | :return: Token. 435 | """ 436 | _check_if_valid(namespace, 'namespace') 437 | _check_if_valid(name, 'name') 438 | 439 | if self.has_attributes() and self.is_in_foreign_mode(): 440 | self._logger.debug('The SecurityContext has been initialized with an access token of a' 441 | ' foreign OAuth Client Id and/or Identity Zone. Furthermore, the' 442 | ' access token contains attributes. Due to the fact that we want to' 443 | ' restrict attribute access to the application that provided the' 444 | ' attributes, the getToken function does not return a' 445 | ' valid token.') 446 | return None 447 | 448 | if namespace != constants.SYSTEM: 449 | self._logger.debug('Namespace "%s" not supported', namespace) 450 | return None 451 | 452 | if name == constants.JOBSCHEDULER: 453 | return self._token 454 | 455 | if name == constants.HDB: 456 | return self._properties.get('saml_token') or self._token 457 | 458 | self._logger.debug('Token name "%s" not supported.', name) 459 | return None 460 | 461 | def get_hdb_token(self): 462 | """:return: Token that can be used for contacting the HANA database. """ 463 | return self.get_token(constants.SYSTEM, constants.HDB) 464 | 465 | def get_app_token(self): 466 | """:return: Application Token that can be used for token forwarding. """ 467 | return self._token 468 | 469 | def check_scope(self, scope): 470 | """ 471 | :param scope: the scope whose existence is checked against 472 | the available scopes of the current user. 473 | Here, the prefix is required, thus the scope string is "globally unique". 474 | 475 | :return: True if the scope is contained in the user's scopes, False otherwise. 476 | """ 477 | _check_if_valid(scope, 'scope') 478 | if scope[:len(constants.XSAPPNAMEPREFIX)] == constants.XSAPPNAMEPREFIX: 479 | scope = scope.replace( 480 | constants.XSAPPNAMEPREFIX, self._properties['xsappname'] + '.') 481 | 482 | return scope in self._properties['scopes'] 483 | 484 | def check_local_scope(self, scope): 485 | """ 486 | :param scope: the scope whose existence is checked against 487 | the available scopes of the current user. Here, no prefix is required. 488 | 489 | :return: True if the scope is contained in the user's scopes, False otherwise. 490 | """ 491 | _check_if_valid(scope, 'scope') 492 | global_scope = self._properties['xsappname'] + '.' + scope 493 | return self.check_scope(global_scope) 494 | 495 | def get_grant_type(self): 496 | """:return: The grant type of the JWT token. """ 497 | return self._properties['grant_type'] 498 | 499 | def get_origin(self): 500 | """ 501 | :return: The user origin. The origin is an alias that refers to a user store in 502 | which the user is persisted. 503 | """ 504 | return self._properties['origin'] 505 | 506 | def get_audience(self): 507 | ''' 508 | :return: The user origin. The origin is an alias that refers to a user store in 509 | which the user is persisted. 510 | ''' 511 | return self._properties['aud'] 512 | 513 | 514 | def get_clone_service_instance_id(self): 515 | """:return: The service instance id of the clone if the XSUAA broker plan is used. """ 516 | return self._properties['service_instance_id'] 517 | 518 | def is_in_foreign_mode(self): 519 | """ 520 | :return: True if the token, that the security context has been 521 | instantiated with, is a foreign token that was not originally 522 | issued for the current application, False otherwise. 523 | """ 524 | return self._properties['is_foreign_mode'] 525 | 526 | def _check_uaa_response(self, response, url, grant_type): 527 | status_code = response.status_code 528 | if status_code == 200: 529 | return 530 | self._logger.debug( 531 | 'Call to %s was not successful, status code: %d, response %s', 532 | url, status_code, response.text) 533 | 534 | if status_code == 401: 535 | raise RuntimeError( 536 | 'Call to /oauth/token was not successful (grant_type: {0}).'.format( 537 | grant_type) + 538 | ' Authorization header invalid, requesting client does not have' + 539 | ' grant_type={0} or no scopes were granted.'.format(constants.GRANTTYPE_JWT_BEARER)) 540 | else: 541 | raise RuntimeError( 542 | 'Call to /oauth/token was not successful (grant_type: {0}).'.format( 543 | grant_type) + ' HTTP status code: {0}'.format(status_code)) 544 | 545 | def _user_token_request_content(self, scopes, client_id): 546 | headers = { 547 | "Accept": "application/json", 548 | "Content-Type": "application/x-www-form-urlencoded", 549 | } 550 | data = { 551 | "grant_type": constants.GRANTTYPE_JWT_BEARER, 552 | "response_type": "token", 553 | "client_id": client_id, 554 | "assertion": self._token, 555 | 'scope': '' if scopes is None else scopes, 556 | } 557 | return headers, data 558 | 559 | def _get_user_token(self, url, client_id, scopes, auth, cert): 560 | headers, data = self._user_token_request_content(scopes, client_id) 561 | with httpx.Client(cert=cert) as client: 562 | response = client.post( 563 | url, 564 | headers=headers, 565 | data=data, 566 | auth=auth, 567 | ) 568 | self._check_uaa_response(response, url, constants.GRANTTYPE_JWT_BEARER) 569 | return response.json()['access_token'] 570 | 571 | async def _get_user_token_async(self, url, client_id, scopes, auth, cert): 572 | assert scopes is not None 573 | 574 | headers, data = self._user_token_request_content(scopes, client_id) 575 | async with httpx.AsyncClient(cert=cert) as client: 576 | response = await client.post( 577 | url, 578 | headers=headers, 579 | data=data, 580 | auth=auth, 581 | ) 582 | 583 | self._check_uaa_response(response, url, constants.GRANTTYPE_JWT_BEARER) 584 | return response.json()["access_token"] 585 | 586 | async def request_token_for_client_async(self, service_credentials, scopes=""): 587 | """ 588 | :param service_credentials: The credentials of the service as dict. 589 | The attributes [clientid, certificate, key and url] or [clientid, clientsecret and url] are mandatory. 590 | 591 | :param scopes: comma-separated list of requested scopes for the token, 592 | e.g. app.scope1,app.scope2. If an empty string is passed, all 593 | scopes are granted. Note that $XSAPPNAME is not supported as part 594 | of the scope names. 595 | 596 | :return: Token. 597 | """ 598 | _check_if_valid(service_credentials, 'service_credentials') 599 | _check_config(service_credentials) 600 | use_mtls = True if _has_client_certificate_props(service_credentials) else False 601 | url = '{}/oauth/token'.format(service_credentials['certurl'] if use_mtls else service_credentials['url']) 602 | cert_file_name, key_file_name = None, None 603 | try: 604 | if use_mtls: 605 | cert_file_name, key_file_name = _create_cert_and_key_files(service_credentials['certificate'], 606 | service_credentials.get('key')) 607 | return await self._get_user_token_async(url, service_credentials['clientid'], scopes, 608 | auth=None if use_mtls else (service_credentials['clientid'], 609 | service_credentials['clientsecret']), 610 | cert=(cert_file_name, key_file_name) if use_mtls else None) 611 | 612 | finally: 613 | _delete_cert_and_key_files(cert_file_name, key_file_name) 614 | 615 | def request_token_for_client(self, service_credentials, scopes=""): 616 | """ 617 | :param service_credentials: The credentials of the service as dict. 618 | The attributes [clientid, certificate, key and url] or [clientid, clientsecret and url] are mandatory. 619 | 620 | :param scopes: comma-separated list of requested scopes for the token, 621 | e.g. app.scope1,app.scope2. If an empty string is passed, all 622 | scopes are granted. Note that $XSAPPNAME is not supported as part 623 | of the scope names. 624 | 625 | :return: Token. 626 | """ 627 | _check_if_valid(service_credentials, 'service_credentials') 628 | _check_config(service_credentials) 629 | use_mtls = True if _has_client_certificate_props(service_credentials) else False 630 | url = '{}/oauth/token'.format(service_credentials['certurl'] if use_mtls else service_credentials['url']) 631 | cert_file_name, key_file_name = None, None 632 | try: 633 | if use_mtls: 634 | cert_file_name, key_file_name = _create_cert_and_key_files(service_credentials['certificate'], 635 | service_credentials.get('key')) 636 | return self._get_user_token(url, service_credentials['clientid'], scopes, 637 | auth=None if use_mtls else (service_credentials['clientid'], 638 | service_credentials['clientsecret']), 639 | cert=(cert_file_name, key_file_name) if use_mtls else None) 640 | 641 | finally: 642 | _delete_cert_and_key_files(cert_file_name, key_file_name) 643 | 644 | def has_attributes(self): 645 | """ 646 | :return: True if the token contains any xs user attributes, False otherwise. 647 | Not available for tokens of grant_type client_credentials. 648 | """ 649 | has_user_attributes = self._get_property_of( 650 | 'user_attributes', self._properties) 651 | if has_user_attributes is not None: 652 | return bool(has_user_attributes) 653 | return None 654 | 655 | def get_attribute(self, name): 656 | """ 657 | :param name: The name of the attribute that is requested. 658 | 659 | :return: The attribute exactly as it is contained in the access token. 660 | If no attribute with the given name is contained in the access token, None is returned. 661 | If the token, that the security context has been instantiated with, 662 | is a foreign token (meaning that the OAuth client contained in the 663 | token and the OAuth client of the current application do not match), 664 | None is returned regardless of whether the requested attribute is contained 665 | in the token or not. 666 | """ 667 | _check_if_valid(name, 'name') 668 | has_attributes = self.has_attributes() 669 | if not has_attributes: 670 | if has_attributes is False: 671 | self._logger.debug( 672 | 'The access token contains no user attributes.') 673 | return None 674 | 675 | if self.is_in_foreign_mode(): 676 | self._logger.debug('The SecurityContext has been initialized with an access token of a' 677 | ' foreign OAuth Client Id and/or Identity Zone. Furthermore, the' 678 | ' access token contains attributes. Due to the fact that we want to' 679 | ' restrict attribute access to the application that provided the' 680 | ' attributes, the getAttribute function does not return any' 681 | ' attributes.') 682 | return None 683 | 684 | if name not in self._properties['user_attributes']: 685 | self._logger.debug( 686 | 'No attribute "%s" found for user "%s".', name, self.get_logon_name()) 687 | return None 688 | return self._properties['user_attributes'][name] 689 | 690 | def get_additional_auth_attribute(self, name): 691 | """ 692 | :param name: The name of the additional authentication attribute that is requested. 693 | 694 | :return: The additional authentication attribute exactly as it is contained in 695 | the access token. If no attribute with the given name is contained in the 696 | access token, None is returned. Note that additional authentication attributes 697 | are also returned in foreign mode (in contrast to getAttribute). 698 | """ 699 | _check_if_valid(name, 'name') 700 | if not bool(self._properties['additional_auth_attributes']): 701 | self._logger.debug( 702 | 'The access token contains no additional authentication attributes.') 703 | return None 704 | 705 | if not name in self._properties['additional_auth_attributes']: 706 | self._logger.debug( 707 | 'No attribute "%s" found as additional authentication attribute.', name) 708 | return None 709 | 710 | return self._properties['additional_auth_attributes'][name] 711 | --------------------------------------------------------------------------------