Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0e0b964
[CDAPI-85]: Initial introduction of APIM Authenticator class
nhsd-jack-wainwright Feb 16, 2026
7705ddb
[CDAPI-85]: Added initial plumbing for lambda to utilise provided env…
nhsd-jack-wainwright Mar 4, 2026
7500fb6
[CDAPI-85]: Moved requests to be a main dependency
nhsd-jack-wainwright Mar 6, 2026
c3654ff
[CDAPI-85]: Minor fix to type conditions within config.py
nhsd-jack-wainwright Mar 6, 2026
3bbade8
[CDAPI-85]: Minor Duration class fix
nhsd-jack-wainwright Mar 6, 2026
74936e5
[CDAPI-85]: Minor lambda environment variable fix
nhsd-jack-wainwright Mar 6, 2026
a910fc6
[CDAPI-85]: Initial unit test fixes WIP
nhsd-jack-wainwright Mar 10, 2026
995efba
[CDAPI-85]: Moved SessionManager directory to use top level /tmp
nhsd-jack-wainwright Mar 10, 2026
78f1eb7
[CDAPI-85]: Added additional logging to APIM authentication
nhsd-jack-wainwright Mar 10, 2026
d1a3474
[CDAPI-85]: Minor poetry.lock fix post rebase
nhsd-jack-wainwright Mar 10, 2026
70b7cf4
[CDAPI-85]: Increased client timeout
nhsd-jack-wainwright Mar 10, 2026
cd646a4
[CDAPI-85]: Added additional logging around APIM authentication
nhsd-jack-wainwright Mar 10, 2026
ed838fb
[CDAPI-85]: Fixed test_handler unit tests
nhsd-jack-wainwright Mar 12, 2026
f37947f
[CDAPI-85]: Bumped pathology-api lambda timeout to 30 seconds
nhsd-jack-wainwright Mar 12, 2026
a367775
[CDAPI-85]: Added type conversion for expiry time within ApimAuthenti…
nhsd-jack-wainwright Mar 12, 2026
e0eeb7f
[CDAPI-85]: Increase memory for pathology-api lambda
nhsd-jack-wainwright Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/preview-env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,18 @@ jobs:
wait_for_lambda_ready
aws lambda update-function-configuration --function-name "$FN" \
--handler "${{ env.LAMBDA_HANDLER }}" \
--memory-size 256 \
--timeout 30 \
--environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \
APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \
APIM_API_KEY_NAME=$API_KEY, \
APIM_MTLS_CERT_NAME=$MTLS_CERT, \
APIM_MTLS_KEY_NAME=$MTLS_KEY, \
APIM_TOKEN_URL=$MOCK_URL/apim, \
PDM_BUNDLE_URL=$MOCK_URL/pdm, \
APIM_KEY_ID=DEV-1, \
APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \
PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \
MNS_EVENT_URL=$MOCK_URL/mns, \
CLIENT_TIMEOUT=1m, \
JWKS_SECRET_NAME=$JWKS_SECRET}" || true
wait_for_lambda_ready
aws lambda update-function-code --function-name "$FN" \
Expand All @@ -173,14 +177,18 @@ jobs:
--handler "${{ env.LAMBDA_HANDLER }}" \
--zip-file "fileb://artifact.zip" \
--role "${{ steps.role-select.outputs.lambda_role }}" \
--memory-size 256 \
--timeout 30 \
--environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \
APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \
APIM_API_KEY_NAME=$API_KEY, \
APIM_KEY_ID=DEV-1, \
APIM_MTLS_CERT_NAME=$MTLS_CERT, \
APIM_MTLS_KEY_NAME=$MTLS_KEY, \
APIM_TOKEN_URL=$MOCK_URL/apim, \
PDM_BUNDLE_URL=$MOCK_URL/pdm, \
APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \
PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \
MNS_EVENT_URL=$MOCK_URL/mns, \
CLIENT_TIMEOUT=1m, \
JWKS_SECRET_NAME=$JWKS_SECRET}" \
--publish
wait_for_lambda_ready
Expand Down
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
"gitlens.ai.enabled": false,
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"pathology-api",
"mocks"
],
"git.enableCommitSigning": true,
"sonarlint.connectedMode.project": {
"connectionId": "nhsdigital",
Expand Down
1 change: 0 additions & 1 deletion pathology-api/lambda_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from pathology_api.logging import get_logger

_logger = get_logger(__name__)

app = APIGatewayHttpResolver()

type _ExceptionHandler[T: Exception] = Callable[[T], Response[str]]
Expand Down
190 changes: 126 additions & 64 deletions pathology-api/poetry.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions pathology-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ readme = "README.md"
requires-python = ">3.13,<4.0.0"
dependencies = [
"aws-lambda-powertools (>=3.24.0,<4.0.0)",
"pydantic (>=2.12.5,<3.0.0)"
"pydantic (>=2.12.5,<3.0.0)",
"pyjwt[crypto] (>=2.11.0,<3.0.0)",
"requests>=2.31.0",
"boto3 (>=1.42.64,<2.0.0)"
]

[tool.poetry]
Expand Down Expand Up @@ -47,7 +50,6 @@ dev = [
"pytest-cov (>=7.0.0,<8.0.0)",
"pytest-html (>=4.1.1,<5.0.0)",
"pact-python>=2.0.0",
"requests>=2.31.0",
"schemathesis>=4.4.1",
"types-requests (>=2.32.4.20250913,<3.0.0.0)",
"types-pyyaml (>=6.0.12.20250915,<7.0.0.0)",
Expand Down
155 changes: 155 additions & 0 deletions pathology-api/src/pathology_api/apim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import functools
import uuid
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import Any, TypedDict

import jwt
import requests

from pathology_api.http import RequestMethod, SessionManager
from pathology_api.logging import get_logger

_logger = get_logger(__name__)


class ApimAuthenticationException(Exception):
pass


class ApimAuthenticator:
class __AccessToken(TypedDict):
value: str
expiry: datetime

def __init__(
self,
private_key: str,
key_id: str,
api_key: str,
token_validity_threshold: timedelta,
token_endpoint: str,
session_manager: SessionManager,
):
self._private_key = private_key
self._key_id = key_id
self._api_key = api_key
self._token_validity_threshold = token_validity_threshold
self._token_endpoint = token_endpoint
self._session_manager = session_manager

self.__access_token: ApimAuthenticator.__AccessToken | None = None

def auth[**P, S](self, func: RequestMethod[P, S]) -> Callable[P, S]:
"""
Decorate a given function with APIM authentication. This authentication will be
provided via a `requests.Session` object.
"""

@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
@self._session_manager.with_session
def with_session(
session: requests.Session, access_token: ApimAuthenticator.__AccessToken
) -> S:
session.headers.update(
{"Authorization": f"Bearer {access_token['value']}"}
)
return func(session, *args, **kwargs)

# If there isn't an access token yet, or the token will expire within the
# token validity threshold, reauthenticate.
if (
self.__access_token is None
or self.__access_token["expiry"] - datetime.now(tz=timezone.utc)
< self._token_validity_threshold
):
_logger.debug("Authenticating with APIM...")
self.__access_token = self._authenticate()

return with_session(self.__access_token)

return wrapper

def _create_client_assertion(self) -> str:
_logger.debug("Creating client assertion JWT for APIM authentication")
claims = {
"sub": self._api_key,
"iss": self._api_key,
"jti": str(uuid.uuid4()),
"aud": self._token_endpoint,
"exp": int(
(datetime.now(tz=timezone.utc) + timedelta(seconds=30)).timestamp()
),
}
_logger.debug(
"Created client claims. jti: %s, exp: %s, aud: %s",
claims["jti"],
claims["exp"],
claims["aud"],
)

try:
client_assertion = jwt.encode(
claims,
self._private_key,
algorithm="RS512",
headers={"kid": self._key_id},
)

_logger.debug("Created client assertion. kid: %s", self._key_id)

return client_assertion
except BaseException:
_logger.exception("Failed to create client assertion JWT")
raise

def _authenticate(self) -> __AccessToken:
@self._session_manager.with_session
def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken:
client_assertion = self._create_client_assertion()

_logger.debug("Sending token request with created session.")

try:
response = session.post(
self._token_endpoint,
data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth"
":client-assertion-type:jwt-bearer",
"client_assertion": client_assertion,
},
)
except BaseException:
_logger.exception("Failed to send authentication request to APIM")
raise

_logger.debug(
"Response received from APIM token endpoint. Status code: %s",
response.status_code,
)

if response.status_code != 200:
raise ApimAuthenticationException(
f"Failed to authenticate with APIM. "
f"Status code: {response.status_code}"
f", Response: {response.text}"
)

response_data = response.json()
_logger.debug(
"APIM authentication successful. Expiry: %s",
response_data["expires_in"],
)

return {
"value": response_data["access_token"],
"expiry": datetime.now(tz=timezone.utc)
+ timedelta(seconds=int(response_data["expires_in"])),
}

_logger.debug(
"Sending authentication request to APIM: %s", self._token_endpoint
)
return with_session()
66 changes: 66 additions & 0 deletions pathology-api/src/pathology_api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import os
import re
from dataclasses import dataclass
from datetime import timedelta
from enum import StrEnum
from typing import cast


class ConfigError(Exception):
pass


class DurationUnit(StrEnum):
SECONDS = "s"
MINUTES = "m"


@dataclass(frozen=True)
class Duration:
unit: DurationUnit
value: int

@property
def timedelta(self) -> timedelta:
match self.unit:
case DurationUnit.SECONDS:
return timedelta(seconds=self.value)
case DurationUnit.MINUTES:
return timedelta(minutes=self.value)


def get_optional_environment_variable[T](name: str, _type: type[T]) -> T | None:
value = os.getenv(name)

if _type is Duration and value is not None:
parsed = re.fullmatch(r"(?P<value>\d+)(?P<unit>[sm])", value)
if parsed is None:
raise ConfigError(f"Invalid duration value: {value!r}")

raw_value = parsed.group("value")
raw_unit = parsed.group("unit")

if not raw_value or not raw_value.isdigit():
raise ConfigError(f"Invalid duration value: {value!r}")

return cast(
"T",
Duration(
unit=DurationUnit(raw_unit),
value=int(raw_value),
),
)
elif value is not None:
if not isinstance(value, _type):
raise ConfigError(f"Environment variable {name!r} is not of type {_type!r}")

return value
else:
return None


def get_environment_variable[T](name: str, _type: type[T]) -> T:
value = get_optional_environment_variable(name=name, _type=_type)
if value is None:
raise ConfigError(f"Environment variable {name!r} is not set")
return value
63 changes: 63 additions & 0 deletions pathology-api/src/pathology_api/handler.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,64 @@
import uuid
from collections.abc import Callable

import requests
from aws_lambda_powertools.utilities import parameters

from pathology_api.apim import ApimAuthenticator
from pathology_api.config import (
Duration,
get_environment_variable,
get_optional_environment_variable,
)
from pathology_api.exception import ValidationError
from pathology_api.fhir.r4.elements import Meta
from pathology_api.fhir.r4.resources import Bundle, Composition
from pathology_api.http import ClientCertificate, SessionManager
from pathology_api.logging import get_logger

_logger = get_logger(__name__)

CLIENT_TIMEOUT = get_environment_variable("CLIENT_TIMEOUT", Duration)

CLIENT_CERTIFICATE_NAME = get_optional_environment_variable("APIM_MTLS_CERT_NAME", str)
CLIENT_KEY_NAME = get_optional_environment_variable("APIM_MTLS_KEY_NAME", str)

APIM_TOKEN_URL = get_environment_variable("APIM_TOKEN_URL", str)
APIM_PRIVATE_KEY_NAME = get_environment_variable("APIM_PRIVATE_KEY_NAME", str)
APIM_API_KEY_NAME = get_environment_variable("APIM_API_KEY_NAME", str)
APIM_TOKEN_EXPIRY_THRESHOLD = get_environment_variable(
"APIM_TOKEN_EXPIRY_THRESHOLD", Duration
)
APIM_KEY_ID = get_environment_variable("APIM_KEY_ID", str)

PDM_URL = get_environment_variable("PDM_BUNDLE_URL", str)

if CLIENT_CERTIFICATE_NAME and CLIENT_KEY_NAME:
certificate = parameters.get_secret(CLIENT_CERTIFICATE_NAME)
key = parameters.get_secret(CLIENT_KEY_NAME)

CLIENT_CERTIFICATE: ClientCertificate | None = {
"certificate": certificate,
"key": key,
}
else:
CLIENT_CERTIFICATE = None


session_manager = SessionManager(
client_timeout=CLIENT_TIMEOUT.timedelta,
client_certificate=CLIENT_CERTIFICATE,
)

apim_authenticator = ApimAuthenticator(
private_key=parameters.get_secret(APIM_PRIVATE_KEY_NAME),
key_id=APIM_KEY_ID,
api_key=parameters.get_secret(APIM_API_KEY_NAME),
token_endpoint=APIM_TOKEN_URL,
token_validity_threshold=APIM_TOKEN_EXPIRY_THRESHOLD.timedelta,
session_manager=session_manager,
)


def _validate_composition(bundle: Bundle) -> None:
compositions = bundle.find_resources(t=Composition)
Expand Down Expand Up @@ -48,4 +99,16 @@ def handle_request(bundle: Bundle) -> Bundle:
)
_logger.debug("Return bundle: %s", return_bundle)

auth_response = _send_request(PDM_URL)
_logger.debug(
"Result of authenticated request. status_code=%s data=%s",
auth_response.status_code,
auth_response.text,
)

return return_bundle


@apim_authenticator.auth
def _send_request(session: requests.Session, url: str) -> requests.Response:
return session.post(url)
Loading
Loading