diff --git a/README.md b/README.md index 817e70b..d80fc43 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ Private key and certificate chain can be also provided as ENVs as given below. I * Private key: `AS_CLIENT_KEY` * Certificate chain: `AS_CLIENT_CRT` +When enabling the requirement of an API-Key for the different +endpoints ([config/as.yml](./config/as.yml#L30))), the actual API-Key can be also provided as ENVs: +* iSHARE flow: `AS_APIKEY_ISHARE` +* Trusted-Issuers-Lists flow: `AS_APIKEY_ISSUER` + In case of very large JWTs in the Authorization header, one needs to increase the max. HTTP header size of gunicorn. This can be done by setting the following ENV (here: max. 32kb): @@ -46,8 +51,10 @@ Further ENVs control the execution of the activation service. Below is a list of | AS_MAX_HEADER_SIZE | 32768 | Maximum header size in bytes | | AS_LOG_LEVEL | 'info' | Log level | | AS_DATABASE_URI | | Database URI to use instead of config from configuration file | -| AS_CLIENT_KEY | | iSHARE private key provided as ENV (compare to [config/as.yml](./config/as.yml#L8)) | -| AS_CLIENT_CERTS | | iSHARE certificate chain provided as ENV (compare to [config/as.yml](./config/as.yml#L10)) | +| AS_CLIENT_KEY | | iSHARE private key provided as ENV (compare to [config/as.yml](./config/as.yml#L8)) | +| AS_CLIENT_CERTS | | iSHARE certificate chain provided as ENV (compare to [config/as.yml](./config/as.yml#L10)) | +| AS_APIKEY_ISHARE | | API-Key for iSHARE flow provided as ENV (compare to [config/as.yml](./config/as.yml#L36)) | +| AS_APIKEY_ISSUER | | API-Key for Trusted-Issuers-List flow provided as ENV (compare to [config/as.yml](./config/as.yml#L46)) | ## Usage diff --git a/api/createpolicy.py b/api/createpolicy.py index 3875b92..823d61a 100644 --- a/api/createpolicy.py +++ b/api/createpolicy.py @@ -1,9 +1,11 @@ from flask import Blueprint, Response, current_app, abort, request from api.util.createpolicy_handler import extract_access_token, get_ar_token, check_create_delegation_evidence, create_delegation_evidence +from api.util.apikey_handler import check_api_key from api.exceptions.create_policy_exception import CreatePolicyException from api.exceptions.database_exception import DatabaseException +from api.exceptions.apikey_exception import ApiKeyException # Blueprint createpolicy_endpoint = Blueprint("createpolicy_endpoint", __name__) @@ -16,6 +18,18 @@ def index(): # Load config conf = current_app.config['as'] + # Check for API-Key + if 'apikeys' in conf: + apikey_conf = conf['apikeys'] + if 'ishare' in apikey_conf and apikey_conf['ishare']['enabledCreatePolicy']: + try: + current_app.logger.debug("Checking API-Key...") + check_api_key(request, apikey_conf['ishare']['headerName'], apikey_conf['ishare']['apiKey']) + except ApiKeyException as ake: + current_app.logger.debug("Checking API-Key not successful: {}. Returning status {}.".format(ake.internal_msg, ake.status_code)) + abort(ake.status_code, ake.public_msg) + current_app.logger.debug("... API-Key accepted") + # Get access token from request header token = None try: diff --git a/api/exceptions/apikey_exception.py b/api/exceptions/apikey_exception.py new file mode 100644 index 0000000..0833164 --- /dev/null +++ b/api/exceptions/apikey_exception.py @@ -0,0 +1,5 @@ +from api.exceptions.as_exception import ActivationServiceException + +class ApiKeyException(ActivationServiceException): + + pass diff --git a/api/issuer.py b/api/issuer.py index fe72f90..3106061 100644 --- a/api/issuer.py +++ b/api/issuer.py @@ -2,10 +2,12 @@ from api.util.issuer_handler import extract_access_token, get_samedevice_redirect_url from api.util.issuer_handler import decode_token_with_jwk, forward_til_request from api.util.issuer_handler import check_create_role, check_update_role, check_delete_role +from api.util.apikey_handler import check_api_key import time from api.exceptions.issuer_exception import IssuerException from api.exceptions.database_exception import DatabaseException +from api.exceptions.apikey_exception import ApiKeyException # Blueprint issuer_endpoint = Blueprint("issuer_endpoint", __name__) @@ -17,7 +19,19 @@ def index(): # Load config conf = current_app.config['as'] - + + # Check for API-Key + if 'apikeys' in conf: + apikey_conf = conf['apikeys'] + if 'ishare' in apikey_conf and apikey_conf['issuer']['enabledIssuer']: + try: + current_app.logger.debug("Checking API-Key...") + check_api_key(request, apikey_conf['issuer']['headerName'], apikey_conf['issuer']['apiKey']) + except ApiKeyException as ake: + current_app.logger.debug("Checking API-Key not successful: {}. Returning status {}.".format(ake.internal_msg, ake.status_code)) + abort(ake.status_code, ake.public_msg) + current_app.logger.debug("... API-Key accepted") + # Check for access token JWT in request header request_token = None try: @@ -42,7 +56,7 @@ def index(): # Received JWT in Authorization header current_app.logger.debug("...received access token JWT in incoming request: {}".format(request_token)) - + # Validate JWT with verifier JWKS payload = None try: @@ -53,7 +67,7 @@ def index(): current_app.logger.debug("Error when validating/decoding: {}. Returning status {}.".format(die.internal_msg, die.status_code)) abort(die.status_code, die.public_msg) current_app.logger.debug("... decoded token payload: {}".format(payload)) - + # Check TIL access depending on HTTP method if request.method == 'POST': # POST: Create issuer flow @@ -100,7 +114,7 @@ def index(): else: # should not happen abort(500, "Invalid HTTP method") - + # Forward request to TIL current_app.logger.debug("... access granted!") try: diff --git a/api/token.py b/api/token.py index 3239041..dd20ce6 100644 --- a/api/token.py +++ b/api/token.py @@ -1,9 +1,11 @@ from flask import Blueprint, Response, current_app, abort, request from api.util.token_handler import forward_token +from api.util.apikey_handler import check_api_key import time from api.exceptions.token_exception import TokenException from api.exceptions.database_exception import DatabaseException +from api.exceptions.apikey_exception import ApiKeyException # Blueprint token_endpoint = Blueprint("token_endpoint", __name__) @@ -15,6 +17,18 @@ def index(): # Load config conf = current_app.config['as'] + + # Check for API-Key + if 'apikeys' in conf: + apikey_conf = conf['apikeys'] + if 'ishare' in apikey_conf and apikey_conf['ishare']['enabledToken']: + try: + current_app.logger.debug("Checking API-Key...") + check_api_key(request, apikey_conf['ishare']['headerName'], apikey_conf['ishare']['apiKey']) + except ApiKeyException as ake: + current_app.logger.debug("Checking API-Key not successful: {}. Returning status {}.".format(ake.internal_msg, ake.status_code)) + abort(ake.status_code, ake.public_msg) + current_app.logger.debug("... API-Key accepted") # Forward token auth_data = None diff --git a/api/util/apikey_handler.py b/api/util/apikey_handler.py new file mode 100644 index 0000000..197b8a9 --- /dev/null +++ b/api/util/apikey_handler.py @@ -0,0 +1,19 @@ +from api.exceptions.apikey_exception import ApiKeyException + +# Check API-Key in request +def check_api_key(request, header_name, api_key): + + # Get header + auth_header = request.headers.get(header_name) + if not auth_header: + message = "Missing API-Key header" + internal_msg = message + " ('{}')".format(header_name) + raise ApiKeyException(message, internal_msg, 400) + + # Check API-Keys + if auth_header != api_key: + msg = "Invalid API-Key" + int_msg = msg + " (provided '{}' != expected '{}')".format(auth_header, api_key) + raise ApiKeyException(msg, int_msg, 400) + + return True diff --git a/api/util/issuer_handler.py b/api/util/issuer_handler.py index 312e1d0..88f2583 100644 --- a/api/util/issuer_handler.py +++ b/api/util/issuer_handler.py @@ -234,7 +234,7 @@ def forward_til_request(request, conf): headers = {k:v for k,v in request.headers if k != "Authorization" and k.lower() != 'host'} url = request.url.replace(request.host_url, f'{til_uri}/') data = request.get_data() - + # Forward request response = requests.request( method = request.method, diff --git a/config/as.yml b/config/as.yml index 340c1f0..b40a958 100644 --- a/config/as.yml +++ b/config/as.yml @@ -26,6 +26,27 @@ db: # Enable SQL logging to stderr echo: true +# Configuration for additional API keys to protect certain endpoints +apikeys: + # Config for iSHARE flow + ishare: + # Header name + headerName: "AS-API-KEY" + # API key (auto-generated if left empty) + apiKey: "" + # Enable for /token endpoint (API key will be required) + enabledToken: true + # Enable for /createpolicy endpoint (API key will be required) + enabledCreatePolicy: false + # Config for Trusted-Issuers-List flow + issuer: + # Header name + headerName: "AS-API-KEY" + # API key (auto-generated if left empty) + apiKey: "" + # Enable for /issuer endpoint (API key will be required) + enabledIssuer: true + # Configuration of iSHARE authorisation registry ar: # Endpoint for token request diff --git a/tests/config/as.yml b/tests/config/as.yml index 6a1d69f..6bcc755 100644 --- a/tests/config/as.yml +++ b/tests/config/as.yml @@ -181,6 +181,27 @@ db: # Enable SQL logging to stderr echo: false +# Configuration for additional API keys to protect certain endpoints +apikeys: + # Config for iSHARE flow + ishare: + # Header name + headerName: "AS-API-KEY" + # API key (auto-generated if left empty) + apiKey: "31f5247c-17e5-4969-95f0-928c8ab16504" + # Enable for /token endpoint (API key will be required) + enabledToken: true + # Enable for /createpolicy endpoint (API key will be required) + enabledCreatePolicy: false + # Config for Trusted-Issuers-List flow + issuer: + # Header name + headerName: "AS-API-KEY" + # API key (auto-generated if left empty) + apiKey: "eb4675ed-860e-4de1-a9a7-3e2e4356d08d" + # Enable for /issuer endpoint (API key will be required) + enabledIssuer: true + # Configuration of authorisation registry ar: # Endpoint for token request @@ -192,3 +213,28 @@ ar: # EORI of AR id: "EU.EORI.DEPROVIDER" +# Configuration specific to Trusted Issuer List /issuer endpoint +issuer: + # clientId parameter + clientId: "some-id" + # Provider DID + providerId: "did:web:packetdelivery.dsba.fiware.dev:did" + # URI of Trusted Issuers List service + tilUri: "http://til.internal" + # URI of verifier + verifierUri: "https://verifier.packetdelivery.net" + # samedevice flow path + samedevicePath: "/api/v1/samedevice" + # JWKS path + jwksPath: "/.well-known/jwks" + # Allowed algorithms for JWT signatures + algorithms: + - "ES256" + # Roles config + roles: + # Role for creating trusted issuer + createRole: "CREATE_ISSUER" + # Role for updating trusted issuer + updateRole: "UPDATE_ISSUER" + # Role for deleting trusted issuer + deleteRole: "DELETE_ISSUER" diff --git a/tests/pytest/test_apikey_handler.py b/tests/pytest/test_apikey_handler.py new file mode 100644 index 0000000..afd79fb --- /dev/null +++ b/tests/pytest/test_apikey_handler.py @@ -0,0 +1,81 @@ +import pytest +from api import app +from tests.pytest.util.config_handler import load_config +from api.util.apikey_handler import check_api_key + +from api.exceptions.apikey_exception import ApiKeyException + +# Get AS config +as_config = load_config("tests/config/as.yml", app) +app.config['as'] = as_config + +@pytest.fixture +def mock_request_apikey_ok_ishare(mocker): + def headers_get(attr): + if attr == "AS-API-KEY": return "31f5247c-17e5-4969-95f0-928c8ab16504" + else: return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + +@pytest.fixture +def mock_request_apikey_ok_issuer(mocker): + def headers_get(attr): + if attr == "AS-API-KEY": return "eb4675ed-860e-4de1-a9a7-3e2e4356d08d" + else: return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + +@pytest.fixture +def mock_request_apikey_invalid_header(mocker): + def headers_get(attr): + if attr == "AS-API-KEY": return "abc" + else: return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + +@pytest.fixture +def mock_request_apikey_no_headers(mocker): + def headers_get(attr): + return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + +@pytest.mark.ok +@pytest.mark.it('should successfully check API-Key for iSHARE flow') +def test_apikey_ok_ishare(mock_request_apikey_ok_ishare): + + # Call function with request mock + try: + check_api_key(mock_request_apikey_ok_ishare, "AS-API-KEY", "31f5247c-17e5-4969-95f0-928c8ab16504") + except Exception as ex: + pytest.fail("should throw no exception: {}".format(ex)) + +@pytest.mark.ok +@pytest.mark.it('should successfully check API-Key for TIL flow') +def test_apikey_ok_issuer(mock_request_apikey_ok_issuer): + + # Call function with request mock + try: + check_api_key(mock_request_apikey_ok_issuer, "AS-API-KEY", "eb4675ed-860e-4de1-a9a7-3e2e4356d08d") + except Exception as ex: + pytest.fail("should throw no exception: {}".format(ex)) + +@pytest.mark.failure +@pytest.mark.it('should throw exception about missing API-Key header') +def test_check_missing_header(mock_request_apikey_no_headers): + + # Call function + with pytest.raises(ApiKeyException, match=r'Missing API-Key header') as ex: + check_api_key(mock_request_apikey_no_headers, "AS-API-KEY", "eb4675ed-860e-4de1-a9a7-3e2e4356d08d") + +@pytest.mark.failure +@pytest.mark.it('should throw exception about invalid API-Key') +def test_check_invalid_header(mock_request_apikey_invalid_header): + + # Call function + with pytest.raises(ApiKeyException, match=r'Invalid API-Key') as ex: + check_api_key(mock_request_apikey_invalid_header, "AS-API-KEY", "eb4675ed-860e-4de1-a9a7-3e2e4356d08d") diff --git a/tests/pytest/test_issuer.py b/tests/pytest/test_issuer.py new file mode 100644 index 0000000..69f22a2 --- /dev/null +++ b/tests/pytest/test_issuer.py @@ -0,0 +1,225 @@ +import pytest +from unittest.mock import patch, MagicMock + +import os +import ast +import json +from api import app + +from tests.pytest.util.config_handler import load_config + +# Valid API-Key +VALID_API_KEY = "eb4675ed-860e-4de1-a9a7-3e2e4356d08d" + +# Get AS config +as_config = load_config("tests/config/as.yml", app) +app.config['as'] = as_config + +# Endpoint of /issuer +ISSUER_ENDPOINT = "{}/issuer".format(as_config['issuer']['tilUri']) +ISSUER_HOST = as_config['issuer']['tilUri'] + +# Response from JWKS endpoint +JWKS_RESPONSE = { + "keys": [ + { + "crv": "P-256", + "kid": "HiS0NXOmYke6dTM7wZGrSwCE_VM0ntqIBMCpFFgEaOU", + "kty": "EC", + "x": "INKfEjYEr7Y2fIOKC30LseENEDLZxf9ZzKtdnz4wXi8", + "y": "aYFyPJhJpwM99SMeYBNJJadJh1RcYbIIaj12x-Jcj8U" + } + ]} + +# Issuer template +TEMPLATE_ISSUER = { + "did": "did:web:happypets.dsba.fiware.dev:did", + "credentials": [ + { + "validFor": { + "from": '2023-02-13T08:15:30Z', + "to": '2023-12-24T20:10:40Z' + }, + "credentialsType": "PacketDeliveryService", + "claims": [ + { + "name": "roles", + "allowedValues": ["GOLD_CUSTOMER", "STANDARD_CUSTOMER"] + } + ] + } + ] +} + +# CREATE_ISSUER template +TEMPLATE_CREATE_ISSUER = { + "aud": [ + "verifier-pdc.dsba.fiware.dev" + ], + "client_id": "did:web:packetdelivery.dsba.fiware.dev:did", + "exp": 1687346995, + "iss": "did:web:packetdelivery.dsba.fiware.dev:did", + "kid": "HiS0NXOmYke6dTM7wZGrSwCE_VM0ntqIBMCpFFgEaOU", + "sub": "did:example:holder", + "verifiableCredential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/suites/jws-2020/v1" + ], + "credentialSchema": { + "id": "https://raw.githubusercontent.com/FIWARE-Ops/i4trust-provider/main/docs/schema.json", + "type": "FullJsonSchemaValidator2021" + }, + "credentialSubject": { + "email": "marketplace@mymail.com", + "id": "0b876000-36c7-47ed-b896-5b5ec163663a", + "roles": [ + { + "names": [ + "CREATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + } + ] + }, + "expirationDate": "2032-12-08T13:05:06Z", + "id": "urn:uuid:c7e7d4a1-7579-40a3-b418-59f6a9f2f6b1", + "issuanceDate": "2023-06-07T07:45:06Z", + "issued": "2023-06-07T07:45:06Z", + "issuer": "did:web:marketplace.dsba.fiware.dev:did", + "proof": { + "created": "2023-06-07T07:45:06Z", + "creator": "did:web:marketplace.dsba.fiware.dev:did", + "jws": "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJQUzI1NiJ9..ThSJV4vVhlxYU3N7OSk6-tnKvdACfY9mhRqCVIf0eLCCSbkEyYV4sevd7AC-HGYJ2KWmMyJTm--gNogt5IuTaa5-oTegYBTyRIc_thZgxJ44_7WfpO-wAY4uyuKzt-TYVhQSWQ5lA_CpT9mL72NAGP4vnNoVvSkXhICB_g7a2Kql4xsR-wZ6htV8W4beDevhu3ajO-Q65KXQPZwInoUpWh1rcrEiyRJz9pI3146d76ikjLDe0rQSMMkm0bDQ86osncuG-HYIbwVF9xKieb6W_MupmWaZRUnvewjm1_ZQCFrKw3UCL6JeShdo7LJpM_bRkXcINEPXBWJp774yv1iyiA", + "type": "JsonWebSignature2020", + "verificationMethod": "did:web:marketplace.dsba.fiware.dev:did#6f4c1255f4a54090bc8ff7365b13a9b7" + }, + "type": [ + "VerifiableCredential", + "ActivationService" + ], + "validFrom": "2023-06-07T07:45:06Z" + } +} + +# Decoded VP token +VP_TOKEN = { + "aud": [ + "verifier-pdc.dsba.fiware.dev" + ], + "client_id": "did:web:packetdelivery.dsba.fiware.dev:did", + "exp": 1687346995, + "iss": "did:web:packetdelivery.dsba.fiware.dev:did", + "kid": "HiS0NXOmYke6dTM7wZGrSwCE_VM0ntqIBMCpFFgEaOU", + "sub": "did:example:holder", + "verifiableCredential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/suites/jws-2020/v1" + ], + "credentialSchema": { + "id": "https://raw.githubusercontent.com/FIWARE-Ops/i4trust-provider/main/docs/schema.json", + "type": "FullJsonSchemaValidator2021" + }, + "credentialSubject": { + "email": "marketplace@mymail.com", + "id": "0b876000-36c7-47ed-b896-5b5ec163663a", + "roles": [ + { + "names": [ + "CREATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + }, + { + "names": [ + "UPDATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + }, + { + "names": [ + "DELETE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + } + ] + }, + "expirationDate": "2032-12-08T13:05:06Z", + "id": "urn:uuid:c7e7d4a1-7579-40a3-b418-59f6a9f2f6b1", + "issuanceDate": "2023-06-07T07:45:06Z", + "issued": "2023-06-07T07:45:06Z", + "issuer": "did:web:marketplace.dsba.fiware.dev:did", + "proof": { + "created": "2023-06-07T07:45:06Z", + "creator": "did:web:marketplace.dsba.fiware.dev:did", + "jws": "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJQUzI1NiJ9..ThSJV4vVhlxYU3N7OSk6-tnKvdACfY9mhRqCVIf0eLCCSbkEyYV4sevd7AC-HGYJ2KWmMyJTm--gNogt5IuTaa5-oTegYBTyRIc_thZgxJ44_7WfpO-wAY4uyuKzt-TYVhQSWQ5lA_CpT9mL72NAGP4vnNoVvSkXhICB_g7a2Kql4xsR-wZ6htV8W4beDevhu3ajO-Q65KXQPZwInoUpWh1rcrEiyRJz9pI3146d76ikjLDe0rQSMMkm0bDQ86osncuG-HYIbwVF9xKieb6W_MupmWaZRUnvewjm1_ZQCFrKw3UCL6JeShdo7LJpM_bRkXcINEPXBWJp774yv1iyiA", + "type": "JsonWebSignature2020", + "verificationMethod": "did:web:marketplace.dsba.fiware.dev:did#6f4c1255f4a54090bc8ff7365b13a9b7" + }, + "type": [ + "VerifiableCredential", + "ActivationService" + ], + "validFrom": "2023-06-07T07:45:06Z" + } +} + +VP_TOKEN_ENCODED = "ewogICJ0eXBlIiA6IFsgIlZlcmlmaWFibGVDcmVkZW50aWFsIiwgIkFjdGl2YXRpb25TZXJ2aWNlIiBdLAogICJAY29udGV4dCIgOiBbICJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsICJodHRwczovL3czaWQub3JnL3NlY3VyaXR5L3N1aXRlcy9qd3MtMjAyMC92MSIgXSwKICAiaWQiIDogInVybjp1dWlkOmM3ZTdkNGExLTc1NzktNDBhMy1iNDE4LTU5ZjZhOWYyZjZiMSIsCiAgImlzc3VlciIgOiAiZGlkOndlYjptYXJrZXRwbGFjZS5kc2JhLmZpd2FyZS5kZXY6ZGlkIiwKICAiaXNzdWFuY2VEYXRlIiA6ICIyMDIzLTA2LTA3VDA3OjQ1OjA2WiIsCiAgImlzc3VlZCIgOiAiMjAyMy0wNi0wN1QwNzo0NTowNloiLAogICJ2YWxpZEZyb20iIDogIjIwMjMtMDYtMDdUMDc6NDU6MDZaIiwKICAiZXhwaXJhdGlvbkRhdGUiIDogIjIwMzItMTItMDhUMTM6MDU6MDZaIiwKICAiY3JlZGVudGlhbFNjaGVtYSIgOiB7CiAgICAiaWQiIDogImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9GSVdBUkUtT3BzL2k0dHJ1c3QtcHJvdmlkZXIvbWFpbi9kb2NzL3NjaGVtYS5qc29uIiwKICAgICJ0eXBlIiA6ICJGdWxsSnNvblNjaGVtYVZhbGlkYXRvcjIwMjEiCiAgfSwKICAiY3JlZGVudGlhbFN1YmplY3QiIDogewogICAgImlkIiA6ICIwYjg3NjAwMC0zNmM3LTQ3ZWQtYjg5Ni01YjVlYzE2MzY2M2EiLAogICAgInJvbGVzIiA6IFsgewogICAgICAibmFtZXMiIDogWyAiQ1JFQVRFX0lTU1VFUiIgXSwKICAgICAgInRhcmdldCIgOiAiZGlkOndlYjpwYWNrZXRkZWxpdmVyeS5kc2JhLmZpd2FyZS5kZXY6ZGlkIgogICAgfSBdLAogICAgImVtYWlsIiA6ICJtYXJrZXRwbGFjZUBteW1haWwuY29tIgogIH0sCiAgInByb29mIiA6IHsKICAgICJ0eXBlIiA6ICJKc29uV2ViU2lnbmF0dXJlMjAyMCIsCiAgICAiY3JlYXRvciIgOiAiZGlkOndlYjptYXJrZXRwbGFjZS5kc2JhLmZpd2FyZS5kZXY6ZGlkIiwKICAgICJjcmVhdGVkIiA6ICIyMDIzLTA2LTA3VDA3OjQ1OjA2WiIsCiAgICAidmVyaWZpY2F0aW9uTWV0aG9kIiA6ICJkaWQ6d2ViOm1hcmtldHBsYWNlLmRzYmEuZml3YXJlLmRldjpkaWQjNmY0YzEyNTVmNGE1NDA5MGJjOGZmNzM2NWIxM2E5YjciLAogICAgImp3cyIgOiAiZXlKaU5qUWlPbVpoYkhObExDSmpjbWwwSWpwYkltSTJOQ0pkTENKaGJHY2lPaUpRVXpJMU5pSjkuLlRoU0pWNHZWaGx4WVUzTjdPU2s2LXRuS3ZkQUNmWTltaFJxQ1ZJZjBlTENDU2JrRXlZVjRzZXZkN0FDLUhHWUoyS1dtTXlKVG0tLWdOb2d0NUl1VGFhNS1vVGVnWUJUeVJJY190aFpneEo0NF83V2ZwTy13QVk0dXl1S3p0LVRZVmhRU1dRNWxBX0NwVDltTDcyTkFHUDR2bk5vVnZTa1hoSUNCX2c3YTJLcWw0eHNSLXdaNmh0VjhXNGJlRGV2aHUzYWpPLVE2NUtYUVBad0lub1VwV2gxcmNyRWl5Ukp6OXBJMzE0NmQ3NmlrakxEZTByUVNNTWttMGJEUTg2b3NuY3VHLUhZSWJ3VkY5eEtpZWI2V19NdXBtV2FaUlVudmV3am0xX1pRQ0ZyS3czVUNMNkplU2hkbzdMSnBNX2JSa1hjSU5FUFhCV0pwNzc0eXYxaXlpQSIKICB9Cn0=" + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + +@pytest.fixture +def mock_post_issuer_empty_response(requests_mock): + return requests_mock.post(ISSUER_ENDPOINT, + json={ }, + status_code=201) + +# Test: Successfully returned redirect 302 +@pytest.mark.ok +@pytest.mark.it('should successfully return a 302 redirect') +def test_issuer_redirect_ok(client): + + # Invoke request + response = client.post("/issuer", + json=TEMPLATE_ISSUER, + headers={ + 'AS-API-KEY': VALID_API_KEY + }) + + # Asserts on response + assert response.status_code == 302, "should return code 302" + + dict_str = list(response.response)[0].decode("UTF-8") + assert "https://verifier.packetdelivery.net/api/v1/samedevice?state=" in dict_str, "response should contain redirect URL" + assert "client_id=some-id" in dict_str, "response should contain client_id" + +# Test: Successfully create issuer +@pytest.mark.ok +@pytest.mark.it('should successfully create the issuer') +@patch('urllib.request.urlopen') +def test_issuer_create_ok(mock_urlopen, client, mock_post_issuer_empty_response, mocker): + + # Mock call to JWKS endpoint + cm = MagicMock() + cm.getcode.return_value = 200 + cm.read.return_value = json.dumps(JWKS_RESPONSE) + cm.__enter__.return_value = cm + mock_urlopen.return_value = cm + + # Mock decoding of vp_token + mocker.patch('api.issuer.decode_token_with_jwk', return_value=VP_TOKEN) + + # Invoke request + response = client.post("/issuer", + json=TEMPLATE_ISSUER, + headers={ + 'AS-API-KEY': VALID_API_KEY, + 'Authorization': "Bearer {}".format(VP_TOKEN_ENCODED) + }) + + # Asserts on response + assert response.status_code == 201, "should return status code 201" diff --git a/tests/pytest/test_issuer_handler.py b/tests/pytest/test_issuer_handler.py new file mode 100644 index 0000000..473c871 --- /dev/null +++ b/tests/pytest/test_issuer_handler.py @@ -0,0 +1,432 @@ +import pytest +from unittest.mock import patch, MagicMock + +#import contextlib +import time, copy, json, jwt +from api import app + +from tests.pytest.util.config_handler import load_config + +from api.util.issuer_handler import extract_access_token +from api.util.issuer_handler import get_samedevice_redirect_url +from api.util.issuer_handler import decode_token_with_jwk +from api.util.issuer_handler import check_role, get_roles_from_payload +from api.util.issuer_handler import check_create_role, check_update_role, check_delete_role +from api.util.issuer_handler import forward_til_request + +from api.exceptions.issuer_exception import IssuerException + +# Get AS config +as_config = load_config("tests/config/as.yml", app) +app.config['as'] = as_config + +# Dummy access token +ACCESS_TOKEN = 'gfgarhgrfha' + +# Endpoint of /issuer +ISSUER_ENDPOINT = "{}/issuer".format(as_config['issuer']['tilUri']) +AS_HOST = "https://as.packetdelivery.com/" +AS_ENDPOINT = "https://as.packetdelivery.com/issuer" + +# JWKS endpoint +verifier_uri = as_config['issuer']['verifierUri'] +jwks_path = as_config['issuer']['jwksPath'] +JWKS_ENDPOINT = "{}{}".format(verifier_uri, jwks_path) + +# Response from JWKS endpoint +JWKS_RESPONSE = { + "keys": [ + { + "crv": "P-256", + "kid": "HiS0NXOmYke6dTM7wZGrSwCE_VM0ntqIBMCpFFgEaOU", + "kty": "EC", + "x": "INKfEjYEr7Y2fIOKC30LseENEDLZxf9ZzKtdnz4wXi8", + "y": "aYFyPJhJpwM99SMeYBNJJadJh1RcYbIIaj12x-Jcj8U" + } + ]} + +# VC token +VC_TOKEN = "eyJhbGciOiJFUzI1NiIsImtpZCI6IkhpUzBOWE9tWWtlNmRUTTd3WkdyU3dDRV9WTTBudHFJQk1DcEZGZ0VhT1UiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsidmVyaWZpZXItcGRjLmRzYmEuZml3YXJlLmRldiJdLCJjbGllbnRfaWQiOiJkaWQ6d2ViOnBhY2tldGRlbGl2ZXJ5LmRzYmEuZml3YXJlLmRldjpkaWQiLCJleHAiOjE2ODczNDY5OTUsImlzcyI6ImRpZDp3ZWI6cGFja2V0ZGVsaXZlcnkuZHNiYS5maXdhcmUuZGV2OmRpZCIsImtpZCI6IkhpUzBOWE9tWWtlNmRUTTd3WkdyU3dDRV9WTTBudHFJQk1DcEZGZ0VhT1UiLCJzdWIiOiJkaWQ6ZXhhbXBsZTpob2xkZXIiLCJ2ZXJpZmlhYmxlQ3JlZGVudGlhbCI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vdzNpZC5vcmcvc2VjdXJpdHkvc3VpdGVzL2p3cy0yMDIwL3YxIl0sImNyZWRlbnRpYWxTY2hlbWEiOnsiaWQiOiJodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vRklXQVJFLU9wcy9pNHRydXN0LXByb3ZpZGVyL21haW4vZG9jcy9zY2hlbWEuanNvbiIsInR5cGUiOiJGdWxsSnNvblNjaGVtYVZhbGlkYXRvcjIwMjEifSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZW1haWwiOiJtYXJrZXRwbGFjZUBteW1haWwuY29tIiwiaWQiOiIwYjg3NjAwMC0zNmM3LTQ3ZWQtYjg5Ni01YjVlYzE2MzY2M2EiLCJyb2xlcyI6W3sibmFtZXMiOlsiQ1JFQVRFX0lTU1VFUiJdLCJ0YXJnZXQiOiJkaWQ6d2ViOnBhY2tldGRlbGl2ZXJ5LmRzYmEuZml3YXJlLmRldjpkaWQifV19LCJleHBpcmF0aW9uRGF0ZSI6IjIwMzItMTItMDhUMTM6MDU6MDZaIiwiaWQiOiJ1cm46dXVpZDpjN2U3ZDRhMS03NTc5LTQwYTMtYjQxOC01OWY2YTlmMmY2YjEiLCJpc3N1YW5jZURhdGUiOiIyMDIzLTA2LTA3VDA3OjQ1OjA2WiIsImlzc3VlZCI6IjIwMjMtMDYtMDdUMDc6NDU6MDZaIiwiaXNzdWVyIjoiZGlkOndlYjptYXJrZXRwbGFjZS5kc2JhLmZpd2FyZS5kZXY6ZGlkIiwicHJvb2YiOnsiY3JlYXRlZCI6IjIwMjMtMDYtMDdUMDc6NDU6MDZaIiwiY3JlYXRvciI6ImRpZDp3ZWI6bWFya2V0cGxhY2UuZHNiYS5maXdhcmUuZGV2OmRpZCIsImp3cyI6ImV5SmlOalFpT21aaGJITmxMQ0pqY21sMElqcGJJbUkyTkNKZExDSmhiR2NpT2lKUVV6STFOaUo5Li5UaFNKVjR2VmhseFlVM043T1NrNi10bkt2ZEFDZlk5bWhScUNWSWYwZUxDQ1Nia0V5WVY0c2V2ZDdBQy1IR1lKMktXbU15SlRtLS1nTm9ndDVJdVRhYTUtb1RlZ1lCVHlSSWNfdGhaZ3hKNDRfN1dmcE8td0FZNHV5dUt6dC1UWVZoUVNXUTVsQV9DcFQ5bUw3Mk5BR1A0dm5Ob1Z2U2tYaElDQl9nN2EyS3FsNHhzUi13WjZodFY4VzRiZURldmh1M2FqTy1RNjVLWFFQWndJbm9VcFdoMXJjckVpeVJKejlwSTMxNDZkNzZpa2pMRGUwclFTTU1rbTBiRFE4Nm9zbmN1Ry1IWUlid1ZGOXhLaWViNldfTXVwbVdhWlJVbnZld2ptMV9aUUNGckt3M1VDTDZKZVNoZG83TEpwTV9iUmtYY0lORVBYQldKcDc3NHl2MWl5aUEiLCJ0eXBlIjoiSnNvbldlYlNpZ25hdHVyZTIwMjAiLCJ2ZXJpZmljYXRpb25NZXRob2QiOiJkaWQ6d2ViOm1hcmtldHBsYWNlLmRzYmEuZml3YXJlLmRldjpkaWQjNmY0YzEyNTVmNGE1NDA5MGJjOGZmNzM2NWIxM2E5YjcifSwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkFjdGl2YXRpb25TZXJ2aWNlIl0sInZhbGlkRnJvbSI6IjIwMjMtMDYtMDdUMDc6NDU6MDZaIn19.a5aFIrDK3P2FJv-Pk3EI7Jn06tZ9RN5JwV8LmLTvXMNG8vVXwXWVUUkahzTB8fqNHeR4RNP3W80O1GSy3JzpGw" + +# Encoded VP token +VP_TOKEN_ENCODED = "ewogICJ0eXBlIiA6IFsgIlZlcmlmaWFibGVDcmVkZW50aWFsIiwgIkFjdGl2YXRpb25TZXJ2aWNlIiBdLAogICJAY29udGV4dCIgOiBbICJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsICJodHRwczovL3czaWQub3JnL3NlY3VyaXR5L3N1aXRlcy9qd3MtMjAyMC92MSIgXSwKICAiaWQiIDogInVybjp1dWlkOmM3ZTdkNGExLTc1NzktNDBhMy1iNDE4LTU5ZjZhOWYyZjZiMSIsCiAgImlzc3VlciIgOiAiZGlkOndlYjptYXJrZXRwbGFjZS5kc2JhLmZpd2FyZS5kZXY6ZGlkIiwKICAiaXNzdWFuY2VEYXRlIiA6ICIyMDIzLTA2LTA3VDA3OjQ1OjA2WiIsCiAgImlzc3VlZCIgOiAiMjAyMy0wNi0wN1QwNzo0NTowNloiLAogICJ2YWxpZEZyb20iIDogIjIwMjMtMDYtMDdUMDc6NDU6MDZaIiwKICAiZXhwaXJhdGlvbkRhdGUiIDogIjIwMzItMTItMDhUMTM6MDU6MDZaIiwKICAiY3JlZGVudGlhbFNjaGVtYSIgOiB7CiAgICAiaWQiIDogImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9GSVdBUkUtT3BzL2k0dHJ1c3QtcHJvdmlkZXIvbWFpbi9kb2NzL3NjaGVtYS5qc29uIiwKICAgICJ0eXBlIiA6ICJGdWxsSnNvblNjaGVtYVZhbGlkYXRvcjIwMjEiCiAgfSwKICAiY3JlZGVudGlhbFN1YmplY3QiIDogewogICAgImlkIiA6ICIwYjg3NjAwMC0zNmM3LTQ3ZWQtYjg5Ni01YjVlYzE2MzY2M2EiLAogICAgInJvbGVzIiA6IFsgewogICAgICAibmFtZXMiIDogWyAiQ1JFQVRFX0lTU1VFUiIgXSwKICAgICAgInRhcmdldCIgOiAiZGlkOndlYjpwYWNrZXRkZWxpdmVyeS5kc2JhLmZpd2FyZS5kZXY6ZGlkIgogICAgfSBdLAogICAgImVtYWlsIiA6ICJtYXJrZXRwbGFjZUBteW1haWwuY29tIgogIH0sCiAgInByb29mIiA6IHsKICAgICJ0eXBlIiA6ICJKc29uV2ViU2lnbmF0dXJlMjAyMCIsCiAgICAiY3JlYXRvciIgOiAiZGlkOndlYjptYXJrZXRwbGFjZS5kc2JhLmZpd2FyZS5kZXY6ZGlkIiwKICAgICJjcmVhdGVkIiA6ICIyMDIzLTA2LTA3VDA3OjQ1OjA2WiIsCiAgICAidmVyaWZpY2F0aW9uTWV0aG9kIiA6ICJkaWQ6d2ViOm1hcmtldHBsYWNlLmRzYmEuZml3YXJlLmRldjpkaWQjNmY0YzEyNTVmNGE1NDA5MGJjOGZmNzM2NWIxM2E5YjciLAogICAgImp3cyIgOiAiZXlKaU5qUWlPbVpoYkhObExDSmpjbWwwSWpwYkltSTJOQ0pkTENKaGJHY2lPaUpRVXpJMU5pSjkuLlRoU0pWNHZWaGx4WVUzTjdPU2s2LXRuS3ZkQUNmWTltaFJxQ1ZJZjBlTENDU2JrRXlZVjRzZXZkN0FDLUhHWUoyS1dtTXlKVG0tLWdOb2d0NUl1VGFhNS1vVGVnWUJUeVJJY190aFpneEo0NF83V2ZwTy13QVk0dXl1S3p0LVRZVmhRU1dRNWxBX0NwVDltTDcyTkFHUDR2bk5vVnZTa1hoSUNCX2c3YTJLcWw0eHNSLXdaNmh0VjhXNGJlRGV2aHUzYWpPLVE2NUtYUVBad0lub1VwV2gxcmNyRWl5Ukp6OXBJMzE0NmQ3NmlrakxEZTByUVNNTWttMGJEUTg2b3NuY3VHLUhZSWJ3VkY5eEtpZWI2V19NdXBtV2FaUlVudmV3am0xX1pRQ0ZyS3czVUNMNkplU2hkbzdMSnBNX2JSa1hjSU5FUFhCV0pwNzc0eXYxaXlpQSIKICB9Cn0=" + +# Decoded VP token +VP_TOKEN = { + "aud": [ + "verifier-pdc.dsba.fiware.dev" + ], + "client_id": "did:web:packetdelivery.dsba.fiware.dev:did", + "exp": 1687346995, + "iss": "did:web:packetdelivery.dsba.fiware.dev:did", + "kid": "HiS0NXOmYke6dTM7wZGrSwCE_VM0ntqIBMCpFFgEaOU", + "sub": "did:example:holder", + "verifiableCredential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/suites/jws-2020/v1" + ], + "credentialSchema": { + "id": "https://raw.githubusercontent.com/FIWARE-Ops/i4trust-provider/main/docs/schema.json", + "type": "FullJsonSchemaValidator2021" + }, + "credentialSubject": { + "email": "marketplace@mymail.com", + "id": "0b876000-36c7-47ed-b896-5b5ec163663a", + "roles": [ + { + "names": [ + "CREATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + }, + { + "names": [ + "UPDATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + }, + { + "names": [ + "DELETE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + } + ] + }, + "expirationDate": "2032-12-08T13:05:06Z", + "id": "urn:uuid:c7e7d4a1-7579-40a3-b418-59f6a9f2f6b1", + "issuanceDate": "2023-06-07T07:45:06Z", + "issued": "2023-06-07T07:45:06Z", + "issuer": "did:web:marketplace.dsba.fiware.dev:did", + "proof": { + "created": "2023-06-07T07:45:06Z", + "creator": "did:web:marketplace.dsba.fiware.dev:did", + "jws": "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJQUzI1NiJ9..ThSJV4vVhlxYU3N7OSk6-tnKvdACfY9mhRqCVIf0eLCCSbkEyYV4sevd7AC-HGYJ2KWmMyJTm--gNogt5IuTaa5-oTegYBTyRIc_thZgxJ44_7WfpO-wAY4uyuKzt-TYVhQSWQ5lA_CpT9mL72NAGP4vnNoVvSkXhICB_g7a2Kql4xsR-wZ6htV8W4beDevhu3ajO-Q65KXQPZwInoUpWh1rcrEiyRJz9pI3146d76ikjLDe0rQSMMkm0bDQ86osncuG-HYIbwVF9xKieb6W_MupmWaZRUnvewjm1_ZQCFrKw3UCL6JeShdo7LJpM_bRkXcINEPXBWJp774yv1iyiA", + "type": "JsonWebSignature2020", + "verificationMethod": "did:web:marketplace.dsba.fiware.dev:did#6f4c1255f4a54090bc8ff7365b13a9b7" + }, + "type": [ + "VerifiableCredential", + "ActivationService" + ], + "validFrom": "2023-06-07T07:45:06Z" + } +} + +TEMPLATE_ISSUER = { + "did": "did:web:happypets.dsba.fiware.dev:did", + "credentials": [ + { + "validFor": { + "from": '2023-02-13T08:15:30Z', + "to": '2023-12-24T20:10:40Z' + }, + "credentialsType": "PacketDeliveryService", + "claims": [ + { + "name": "roles", + "allowedValues": ["GOLD_CUSTOMER", "STANDARD_CUSTOMER"] + } + ] + } + ] +} + +# Tests for function extract_access_token(request) +class TestExtractAccessToken: + + @pytest.fixture + def mock_request_ok(self, mocker): + def headers_get(attr): + if attr == "Authorization": return "Bearer " + ACCESS_TOKEN + else: return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + + @pytest.fixture + def mock_request_empty_token(self, mocker): + def headers_get(attr): + if attr == "Authorization": return "Bearer " + else: return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + + @pytest.fixture + def mock_request_invalid_header(self, mocker): + def headers_get(attr): + if attr == "Authorization": return "Bearer " + ACCESS_TOKEN + " invalid" + else: return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + + @pytest.fixture + def mock_request_no_bearer(self, mocker): + def headers_get(attr): + if attr == "Authorization": return ACCESS_TOKEN + else: return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + + @pytest.fixture + def mock_request_no_headers(self, mocker): + def headers_get(attr): + return None + request = mocker.Mock() + request.headers.get.side_effect = headers_get + return request + + @pytest.mark.ok + @pytest.mark.it('should successfully extract access token') + def test_extract_ok(self, mock_request_ok): + + # Call function with request mock + token = extract_access_token(mock_request_ok) + assert token == ACCESS_TOKEN, "should return correct access token" + + @pytest.mark.ok + @pytest.mark.it('should return None due to missing Authorization header') + def test_extract_missing_header(self, mock_request_no_headers): + + # Call function with request mock + token = extract_access_token(mock_request_no_headers) + assert token is None, "return value should be None" + + @pytest.mark.failure + @pytest.mark.it('should fail due to missing Bearer in Authorization header') + def test_extract_missing_bearer(self, mock_request_no_bearer): + + # Call function with request mock + with pytest.raises(IssuerException, match=r'Invalid Authorization header'): + token = extract_access_token(mock_request_no_bearer) + + @pytest.mark.failure + @pytest.mark.it('should fail due to invalid Authorization header') + def test_extract_invalid_header(self, mock_request_invalid_header): + + # Call function with request mock + with pytest.raises(IssuerException, match=r'Invalid Authorization header'): + token = extract_access_token(mock_request_invalid_header) + + @pytest.mark.failure + @pytest.mark.it('should fail due to empty_token') + def test_extract_empty_token(self, mock_request_empty_token): + + # Call function with request mock + with pytest.raises(IssuerException, match=r'Invalid Authorization header, empty token'): + token = extract_access_token(mock_request_empty_token) + + +# Tests for function get_samedevice_redirect_url(conf) +class TestGetSamedeviceRedirectURL: + + @pytest.mark.ok + @pytest.mark.it('should return redirect URL') + def test_redirect_url_ok(self): + + # Call function + url = get_samedevice_redirect_url(as_config) + assert "https://verifier.packetdelivery.net/api/v1/samedevice?state=" in url, "URL should contain correct host and endpoint" + assert "client_id=some-id" in url, "URL should contain client_id" + + +# Tests for decode_token_with_jwk(token, conf) +class TestDecodeTokenWithJwk: + + @pytest.mark.ok + @pytest.mark.it('should successfully decode the token and return the payload') + @patch('urllib.request.urlopen') + def test_token_ok(self, mock_urlopen, mocker): + + # Mock call to JWKS endpoint + cm = MagicMock() + cm.getcode.return_value = 200 + cm.read.return_value = json.dumps(JWKS_RESPONSE) + cm.__enter__.return_value = cm + mock_urlopen.return_value = cm + + # Mock decoding of vp_token + mocker.patch('jwt.decode', return_value=VP_TOKEN) + + # Call function + payload = decode_token_with_jwk(VC_TOKEN, as_config) + + # Asserts + assert payload['client_id'] == "did:web:packetdelivery.dsba.fiware.dev:did", "should return correct client_id" + assert payload['verifiableCredential']['issuer'] == "did:web:marketplace.dsba.fiware.dev:did", "should return correct issuer of credential" + +# Tests for check_role(credential_roles, required_role, provider_id) +class TestCheckRole: + + CREDENTIAL_ROLES = [ + { + "names": [ + "CREATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + } + ] + + @pytest.mark.ok + @pytest.mark.it('should successfully find the required role') + def test_role_ok(self): + + REQUIRED_ROLE = "CREATE_ISSUER" + TARGET_DID = "did:web:packetdelivery.dsba.fiware.dev:did" + + # Call function + assert check_role(self.CREDENTIAL_ROLES, REQUIRED_ROLE, TARGET_DID) + + @pytest.mark.failure + @pytest.mark.it('should fail finding the required role') + def test_role_fail(self): + + REQUIRED_ROLE = "READ_ISSUER" + TARGET_DID = "did:web:packetdelivery.dsba.fiware.dev:did" + + # Call function + assert not check_role(self.CREDENTIAL_ROLES, REQUIRED_ROLE, TARGET_DID) + +# Tests for get_roles_from_payload(token_payload) +class TestGetRolesFromPayload: + + @pytest.mark.ok + @pytest.mark.it('should successfully get the roles from the VP token payload') + def test_role_ok(self): + + REQUIRED_ROLE = "CREATE_ISSUER" + + # Call function + roles = get_roles_from_payload(VP_TOKEN) + + assert len(roles) > 0, "should not be empty" + assert roles[0]['names'][0] == REQUIRED_ROLE, "should contain correct role" + +# Tests for check_create_role(token_payload, conf) +class TestCheckCreateRole: + + @pytest.mark.ok + @pytest.mark.it('should successfully check for the CREATE role in the VP token payload') + def test_role_ok(self): + + # Call function + assert check_create_role(VP_TOKEN, as_config), "should return True" + + @pytest.mark.failure + @pytest.mark.it('should fail checking for the CREATE role') + def test_role_fail(self): + + # Prepare payload + payload = copy.deepcopy(VP_TOKEN) + payload['verifiableCredential']['credentialSubject']['roles'] = [ + { + "names": [ + "UPDATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + } + ] + + # Call function + assert not check_create_role(payload, as_config), "should return False" + + +# Tests for check_update_role(token_payload, conf) +class TestCheckUpdateRole: + + @pytest.mark.ok + @pytest.mark.it('should successfully check for the UPDATE role in the VP token payload') + def test_role_ok(self): + + # Call function + assert check_update_role(VP_TOKEN, as_config), "should return True" + + @pytest.mark.failure + @pytest.mark.it('should fail checking for the UPDATE role') + def test_role_fail(self): + + # Prepare payload + payload = copy.deepcopy(VP_TOKEN) + payload['verifiableCredential']['credentialSubject']['roles'] = [ + { + "names": [ + "CREATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + } + ] + + # Call function + assert not check_update_role(payload, as_config), "should return False" + +# Tests for check_delete_role(token_payload, conf) +class TestCheckDeleteRole: + + @pytest.mark.ok + @pytest.mark.it('should successfully check for the DELETE role in the VP token payload') + def test_role_ok(self): + + # Call function + assert check_delete_role(VP_TOKEN, as_config), "should return True" + + @pytest.mark.failure + @pytest.mark.it('should fail checking for the DELETE role') + def test_role_fail(self): + + # Prepare payload + payload = copy.deepcopy(VP_TOKEN) + payload['verifiableCredential']['credentialSubject']['roles'] = [ + { + "names": [ + "UPDATE_ISSUER" + ], + "target": "did:web:packetdelivery.dsba.fiware.dev:did" + } + ] + + # Call function + assert not check_delete_role(payload, as_config), "should return False" + +# Tests for forward_til_request(request, conf) +class TestForwardTilRequest: + + @pytest.fixture + def mock_request_ok(self, mocker): + def data_get(): + return TEMPLATE_ISSUER + + headers = [{ + "AS-API-KEY": "eb4675ed-860e-4de1-a9a7-3e2e4356d08d", + "Authorization": ACCESS_TOKEN + }] + cookies = [] + + request = mocker.Mock() + request.json = TEMPLATE_ISSUER + request.get_data.side_effect = data_get + request.headers = headers + request.cookies = cookies + request.method = "POST" + request.url = AS_ENDPOINT #ISSUER_ENDPOINT + request.host_url = AS_HOST #ISSUER_HOST + return request + + @pytest.fixture + def mock_post_issuer_empty_response(self, requests_mock): + return requests_mock.post(ISSUER_ENDPOINT, + json={ }, + status_code=201) + + @pytest.mark.ok + @pytest.mark.it('should successfully forward the POST request') + def test_post_issuer_ok(self, mock_request_ok, mock_post_issuer_empty_response): + + # Call function + response = forward_til_request(mock_request_ok, as_config) + + # Asserts on response + assert response.status_code == 201, "should return status code 201" diff --git a/tests/pytest/test_policy_flow.py b/tests/pytest/test_policy_flow.py index e224591..51b8e84 100644 --- a/tests/pytest/test_policy_flow.py +++ b/tests/pytest/test_policy_flow.py @@ -40,6 +40,9 @@ # Client EORI CLIENT_EORI = 'EU.EORI.DEMARKETPLACE' +# Valid API-Key +VALID_API_KEY = "31f5247c-17e5-4969-95f0-928c8ab16504" + # Form parameters REQ_FORM = { 'client_id': CLIENT_EORI, @@ -162,7 +165,10 @@ def mock_post_policy_ok(requests_mock): def test_policy_flow_ok(client, mock_post_token_ok, mock_post_delegation_ok, mock_post_policy_ok, clean_db): # Invoke request for /token - response = client.post(TOKEN_ENDPOINT, data=REQ_FORM) + response = client.post(TOKEN_ENDPOINT, data=REQ_FORM, + headers={ + 'AS-API-KEY': VALID_API_KEY + }) # Asserts on response assert mock_post_token_ok.called diff --git a/tests/pytest/test_token.py b/tests/pytest/test_token.py index bec27ab..80f48d8 100644 --- a/tests/pytest/test_token.py +++ b/tests/pytest/test_token.py @@ -1,10 +1,14 @@ import pytest import os +import ast from api import app from flask_sqlalchemy import SQLAlchemy from tests.pytest.util.config_handler import load_config +# Valid API-Key +VALID_API_KEY = "31f5247c-17e5-4969-95f0-928c8ab16504" + # Get AS config as_config = load_config("tests/config/as.yml", app) app.config['as'] = as_config @@ -69,7 +73,11 @@ def mock_post_token_ok(requests_mock): def test_token_ok(client, mock_post_token_ok, clean_db): # Invoke request - response = client.post("/token", data=REQ_FORM) + response = client.post("/token", + data=REQ_FORM, + headers={ + 'AS-API-KEY': VALID_API_KEY + }) # Asserts on response assert mock_post_token_ok.called @@ -85,6 +93,43 @@ def test_token_ok(client, mock_post_token_ok, clean_db): assert db_token.eori == CLIENT_EORI, "DB entry should have correct EORI" assert db_token.access_token == ACCESS_TOKEN, "DB entry should have correct access token" +# Test: Failure missing api-key header +@pytest.mark.failure +@pytest.mark.it('should fail due to missing API-Key header') +def test_token_no_apikey_header(client, mock_post_token_ok, clean_db): + + # Invoke request + response = client.post("/token", data=REQ_FORM) + + # Asserts on response + assert response.status_code == 400, "should return code 400" + + dict_str = list(response.response)[0].decode("UTF-8") + response_dict = ast.literal_eval(dict_str) + assert response_dict['message'] == "Bad Request", "should return 'Bad Request'" + assert response_dict['code'] == 400, "should return code 400" + assert response_dict['description'] == "Missing API-Key header", "should return description 'Missing API-Key header'" + +# Test: Failure invalid api-key +@pytest.mark.failure +@pytest.mark.it('should fail due to invalid API-Key') +def test_token_invalid_apikey(client, mock_post_token_ok, clean_db): + + # Invoke request + response = client.post("/token", data=REQ_FORM, + headers={ + 'AS-API-KEY': 'abc' + }) + + # Asserts on response + assert response.status_code == 400, "should return code 400" + + dict_str = list(response.response)[0].decode("UTF-8") + response_dict = ast.literal_eval(dict_str) + assert response_dict['message'] == "Bad Request", "should return 'Bad Request'" + assert response_dict['code'] == 400, "should return code 400" + assert response_dict['description'] == "Invalid API-Key", "should return description 'Invalid API-Key'" + # Test: Failure missing client_id @pytest.mark.failure @pytest.mark.it('should fail due to missing client_id') @@ -95,7 +140,16 @@ def test_token_missing_id(client, mock_post_token_ok, clean_db): form.pop('client_id', None) # Invoke request - response = client.post("/token", data=form) + response = client.post("/token", data=form, + headers={ + 'AS-API-KEY': VALID_API_KEY + }) # Asserts on response assert response.status_code == 400, "should return code 400" + + dict_str = list(response.response)[0].decode("UTF-8") + response_dict = ast.literal_eval(dict_str) + assert response_dict['message'] == "Bad Request", "should return 'Bad Request'" + assert response_dict['code'] == 400, "should return code 400" + assert response_dict['description'] == "Invalid form parameters: Missing client_id", "should return correct description" diff --git a/wsgi.py b/wsgi.py index 2a8dded..a87200f 100644 --- a/wsgi.py +++ b/wsgi.py @@ -2,6 +2,7 @@ from flask_sqlalchemy import SQLAlchemy import logging, os import yaml, sys +import uuid # Port port = int(os.environ.get("AS_PORT", 8080)) @@ -28,10 +29,48 @@ except FileNotFoundError as fnfe: app.logger.error('Could not load config file: {}'.format(fnfe)) sys.exit(4) +conf = app.config['as'] + + +# Init API-Key config +app.logger.info("Init API-Key config...") +if 'apikeys' in conf: + apikey_conf = conf['apikeys'] + + # iSHARE flow + if apikey_conf['ishare'] and (apikey_conf['ishare']['enabledToken'] or apikey_conf['ishare']['enabledCreatePolicy']): + app.logger.info("... enabled for iSHARE flows ...") + if 'apiKey' not in apikey_conf['ishare'] or len(apikey_conf['ishare']['apiKey']) < 1: + # Check for ENV + if os.environ.get('AS_APIKEY_ISHARE'): + app.logger.info("... ... no API-Key provided in config, using API-Key from ENV ...") + apikey_conf['ishare']['apiKey'] = os.environ.get('AS_APIKEY_ISHARE') + else: + app.logger.info("... ... no API-Key provided, generating random API-Key ...") + apikey_conf['ishare']['apiKey'] = str(uuid.uuid4()) + if apikey_conf['ishare']['enabledToken']: + app.logger.info("... ... requiring API-Key in Header '{}' for /token endpoint: {}".format(apikey_conf['ishare']['headerName'], apikey_conf['ishare']['apiKey'])) + if apikey_conf['ishare']['enabledCreatePolicy']: + app.logger.info("... ... requiring API-Key in Header '{}' for /createpolicy endpoint: {}".format(apikey_conf['ishare']['headerName'], apikey_conf['ishare']['apiKey'])) + + # TIL flow + if apikey_conf['issuer'] and apikey_conf['issuer']['enabledIssuer']: + app.logger.info("... enabled for Trusted-Issuers-List flow ...") + if 'apiKey' not in apikey_conf['issuer'] or len(apikey_conf['issuer']['apiKey']) < 1: + if os.environ.get('AS_APIKEY_ISSUER'): + app.logger.info("... ... no API-Key provided in config, using API-Key from ENV ...") + apikey_conf['issuer']['apiKey'] = os.environ.get('AS_APIKEY_ISSUER') + else: + app.logger.info("... ... no API-Key provided, generating random API-Key ...") + apikey_conf['issuer']['apiKey'] = str(uuid.uuid4()) + app.logger.info("... ... requiring API-Key in Header '{}' for /issuer endpoint: {}".format(apikey_conf['issuer']['headerName'], apikey_conf['issuer']['apiKey'])) + +else: + app.logger.info("... no 'apikeys' config found. Not requiring any API Keys!") + # Create database app.logger.info("Creating database...") -conf = app.config['as'] if 'db' not in conf: app.logger.error('No database configuration in config file') sys.exit(4)