Skip to content

Commit

Permalink
Merge pull request #18 from i4Trust/apikey-tests
Browse files Browse the repository at this point in the history
Adding optional API-Key requirement
  • Loading branch information
dwendland authored Jun 22, 2023
2 parents 9dafdf8 + 999b901 commit 61de30c
Show file tree
Hide file tree
Showing 15 changed files with 988 additions and 11 deletions.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions api/createpolicy.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions api/exceptions/apikey_exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from api.exceptions.as_exception import ActivationServiceException

class ApiKeyException(ActivationServiceException):

pass
22 changes: 18 additions & 4 deletions api/issuer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions api/token.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions api/util/apikey_handler.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion api/util/issuer_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions config/as.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions tests/config/as.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
81 changes: 81 additions & 0 deletions tests/pytest/test_apikey_handler.py
Original file line number Diff line number Diff line change
@@ -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")
Loading

0 comments on commit 61de30c

Please sign in to comment.