diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 3c5a3418..296b64cd 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "anyio" @@ -753,7 +753,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format\""} idna = {version = "*", optional = true, markers = "extra == \"format\""} isoduration = {version = "*", optional = true, markers = "extra == \"format\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format\""} -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format\""} rfc3987 = {version = "*", optional = true, markers = "extra == \"format\""} @@ -1494,6 +1494,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.11.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, + {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] + [[package]] name = "pyrate-limiter" version = "3.9.0" @@ -2424,5 +2442,5 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" -python-versions = ">3.13,<4.0.0" -content-hash = "a452bd22e2386a3ff58b4c7a5ac2cb571de9e3d49a4fbc161ffd3aafa2a7bf44" +python-versions = ">=3.14,<4.0.0" +content-hash = "161eb0c3f2fa94f8b8a90196db766f1c7ce006eb698852d3bd92adfed98455d1" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index a841d21e..8330ee5d 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -6,13 +6,14 @@ authors = [ {name = "Your Name", email = "you@example.com"} ] readme = "README.md" -requires-python = ">3.13,<4.0.0" +requires-python = ">=3.14,<4.0.0" [tool.poetry.dependencies] clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } flask = "^3.1.2" types-flask = "^1.1.6" requests = "^2.32.5" +pyjwt = "^2.11.0" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, diff --git a/gateway-api/src/gateway_api/clinical_jwt/__init__.py b/gateway-api/src/gateway_api/clinical_jwt/__init__.py new file mode 100644 index 00000000..5b6effaf --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/__init__.py @@ -0,0 +1,5 @@ +from .device import Device +from .jwt import JWT +from .practitioner import Practitioner + +__all__ = ["JWT", "Device", "Practitioner"] diff --git a/gateway-api/src/gateway_api/clinical_jwt/device.py b/gateway-api/src/gateway_api/clinical_jwt/device.py new file mode 100644 index 00000000..d0d28864 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/device.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class Device: + system: str + value: str + model: str + version: str + + @property + def json(self) -> str: + outstr = f""" + {{ + "resourceType": "Device", + "identifier": [ + {{ + "system": "{self.system}", + "value": "{self.value}" + }} + ], + "model": "{self.model}", + "version": "{self.version}" + }} + """ + return outstr.strip() + + def __str__(self) -> str: + return self.json diff --git a/gateway-api/src/gateway_api/clinical_jwt/jwt.py b/gateway-api/src/gateway_api/clinical_jwt/jwt.py new file mode 100644 index 00000000..c8ec78cb --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/jwt.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from time import time +from typing import Any + +import jwt as pyjwt + + +@dataclass(frozen=True, kw_only=True) +class JWT: + issuer: str + subject: str + audience: str + requesting_device: str + requesting_organization: str + requesting_practitioner: str + + # Time fields + issued_at: int = field(default_factory=lambda: int(time())) + expiration: int = field(default_factory=lambda: int(time()) + 300) + + # These are here for future proofing but are not expected ever to be changed + algorithm: str | None = None + type: str = "JWT" + reason_for_request: str = "directcare" + requested_scope: str = "patient/*.read" + + @property + def issue_time(self) -> str: + return datetime.fromtimestamp(self.issued_at, tz=UTC).isoformat() + + @property + def exp_time(self) -> str: + return datetime.fromtimestamp(self.expiration, tz=UTC).isoformat() + + def encode(self) -> str: + return pyjwt.encode( + self.payload(), + key=None, + algorithm=self.algorithm, + headers={"typ": self.type}, + ) + + @staticmethod + def decode(token: str) -> "JWT": + token_dict = pyjwt.decode( + token, + options={"verify_signature": False}, # NOSONAR S5659 (not signed) + ) + + return JWT( + issuer=token_dict["iss"], + subject=token_dict["sub"], + audience=token_dict["aud"], + expiration=token_dict["exp"], + issued_at=token_dict["iat"], + requesting_device=token_dict["requesting_device"], + requesting_organization=token_dict["requesting_organization"], + requesting_practitioner=token_dict["requesting_practitioner"], + ) + + def payload(self) -> dict[str, Any]: + return { + "iss": self.issuer, + "sub": self.subject, + "aud": self.audience, + "exp": self.expiration, + "iat": self.issued_at, + "requesting_device": self.requesting_device, + "requesting_organization": self.requesting_organization, + "requesting_practitioner": self.requesting_practitioner, + "reason_for_request": self.reason_for_request, + "requested_scope": self.requested_scope, + } + + def __str__(self) -> str: + return self.encode() diff --git a/gateway-api/src/gateway_api/clinical_jwt/practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py new file mode 100644 index 00000000..17d155e9 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class Practitioner: + id: str + sds_userid: str + role_profile_id: str + userid_url: str + userid_value: str + family_name: str + given_name: str | None = None + prefix: str | None = None + + def __post_init__(self) -> None: + given = "" if self.given_name is None else f',"given":["{self.given_name}"]' + prefix = "" if self.prefix is None else f',"prefix":["{self.prefix}"]' + self._name_str = f'[{{"family": "{self.family_name}"{given}{prefix}}}]' + + @property + def json(self) -> str: + user_id_system = "https://fhir.nhs.uk/Id/sds-user-id" + role_id_system = "https://fhir.nhs.uk/Id/sds-role-profile-id" + + outstr = f""" + {{ + "resourceType": "Practitioner", + "id": "{self.id}", + "identifier": [ + {{ + "system": "{user_id_system}", + "value": "{self.sds_userid}" + }}, + {{ + "system": "{role_id_system}", + "value": "{self.role_profile_id}" + }}, + {{ + "system": "{self.userid_url}", + "value": "{self.userid_value}" + }} + ], + "name": {self._name_str} + }} + """ + return outstr.strip() + + def __str__(self) -> str: + return self.json diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_device.py b/gateway-api/src/gateway_api/clinical_jwt/test_device.py new file mode 100644 index 00000000..d3f46138 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_device.py @@ -0,0 +1,62 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.device`. +""" + +from json import loads + +from gateway_api.clinical_jwt import Device + + +def test_device_creation_with_all_required_fields() -> None: + """ + Test that a Device instance can be created with all required fields. + """ + device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + assert device.system == "https://consumersupplier.com/Id/device-identifier" + assert device.value == "CONS-APP-4" + assert device.model == "Consumer product name" + assert device.version == "5.3.0" + + +def test_device_json_property_returns_valid_json_structure() -> None: + """ + Test that the json property returns a valid JSON structure for requesting_device. + """ + input_device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + json_output = input_device.json + jdict = loads(json_output) + + output_device = Device( + system=jdict["identifier"][0]["system"], + value=jdict["identifier"][0]["value"], + model=jdict["model"], + version=jdict["version"], + ) + + assert input_device == output_device + + +def test_device_str_returns_json() -> None: + """ + Test that __str__ returns the same value as the json property. + """ + device = Device( + system="https://test.com/device", + value="TEST-001", + model="Test Model", + version="1.0.0", + ) + + assert str(device) == device.json diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py new file mode 100644 index 00000000..418fb039 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py @@ -0,0 +1,174 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.jwt`. +""" + +from unittest.mock import Mock, patch + +import jwt as pyjwt +import pytest + +from gateway_api.clinical_jwt import JWT + + +def test_jwt_creation_with_required_fields() -> None: + """ + Test that a JWT instance can be created with all required fields. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + ) + + assert token.issuer == "https://example.com" + assert token.subject == "user-123" + assert token.audience == "https://provider.example.com" + assert token.requesting_device == '{"device": "info"}' + assert token.requesting_organization == "ORG-123" + assert token.requesting_practitioner == '{"practitioner": "info"}' + assert token.algorithm is None + assert token.type == "JWT" + assert token.reason_for_request == "directcare" + assert token.requested_scope == "patient/*.read" + + +@patch("gateway_api.clinical_jwt.jwt.time") +def test_jwt_default_issued_at_and_expiration(mock_time: Mock) -> None: + """ + Test that issued_at and expiration have correct default values. + """ + mock_time.return_value = 1000.0 + + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + ) + + assert token.issued_at == 1000 + assert token.expiration == 1300 # issued_at + 300 + + +def test_jwt_issue_time_property() -> None: + """ + Test that issue_time property returns ISO formatted timestamp. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1609459200, # 2021-01-01 00:00:00 UTC + ) + + assert token.issue_time == "2021-01-01T00:00:00+00:00" + + +def test_jwt_exp_time_property() -> None: + """ + Test that exp_time property returns ISO formatted timestamp. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + expiration=1609459500, # 2021-01-01 00:05:00 UTC + ) + + assert token.exp_time == "2021-01-01T00:05:00+00:00" + + +def test_jwt_payload_contains_all_required_fields() -> None: + """ + Test that payload() returns a dictionary with all required JWT fields. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1000, + expiration=1300, + ) + + payload = token.payload() + + expected = { + "iss": token.issuer, + "sub": token.subject, + "aud": token.audience, + "exp": token.expiration, + "iat": token.issued_at, + "requesting_device": token.requesting_device, + "requesting_organization": token.requesting_organization, + "requesting_practitioner": token.requesting_practitioner, + "reason_for_request": token.reason_for_request, + "requested_scope": token.requested_scope, + } + + assert payload == expected + + +def test_jwt_encode_returns_string() -> None: + """ + Test that encode() returns a valid JWT token string with correct structure. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1000, + expiration=1300, + ) + + encoded = token.encode() + + # Use PyJWT to decode and verify the token structure + try: + pyjwt.decode( + encoded, + options={"verify_signature": False}, # NOSONAR S5659 (not signed) + ) + except pyjwt.DecodeError as err: + pytest.fail(f"Failed to decode JWT: {err}") + except Exception as err: + pytest.fail(f"Unexpected error during JWT decoding: {err}") + + +def test_jwt_decode_reconstructs_token() -> None: + """ + Test that decode() can reconstruct a JWT from an encoded token string. + """ + original = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + issued_at=1000, + expiration=1300, + ) + + encoded = original.encode() + decoded = JWT.decode(encoded) + + assert decoded == original, ( + f"The decoded token, {decoded}, does not match the original, {original}" + ) diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py new file mode 100644 index 00000000..78ea5065 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py @@ -0,0 +1,100 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.practitioner`. +""" + +from json import loads + +from gateway_api.clinical_jwt import Practitioner + + +def test_practitioner_creation_with_all_fields() -> None: + """ + Test that a Practitioner instance can be created with all fields. + """ + practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + assert practitioner.id == "10019" + assert practitioner.sds_userid == "111222333444" + assert practitioner.role_profile_id == "444555666777" + assert practitioner.userid_url == "https://consumersupplier.com/Id/user-guid" + assert practitioner.userid_value == "98ed4f78-814d-4266-8d5b-cde742f3093c" + assert practitioner.family_name == "Doe" + assert practitioner.given_name == "John" + assert practitioner.prefix == "Mr" + + +def test_practitioner_json_property_returns_valid_structure() -> None: + """ + Test that the json property returns a valid JSON structure for + requesting_practitioner. + """ + input_practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + json_output = input_practitioner.json + jdict = loads(json_output) + output_practitioner = Practitioner( + id=jdict["id"], + sds_userid=jdict["identifier"][0]["value"], + role_profile_id=jdict["identifier"][1]["value"], + userid_url=jdict["identifier"][2]["system"], + userid_value=jdict["identifier"][2]["value"], + family_name=jdict["name"][0]["family"], + given_name=jdict["name"][0].get("given", [None])[0], + prefix=jdict["name"][0].get("prefix", [None])[0], + ) + + assert input_practitioner == output_practitioner + + +def test_practitioner_str_returns_json() -> None: + """ + Test that __str__ returns the same value as the json property. + """ + practitioner = Practitioner( + id="10026", + sds_userid="888999000111", + role_profile_id="111222333444", + userid_url="https://test.com/user", + userid_value="test-guid-7", + family_name="Taylor", + ) + + assert str(practitioner) == practitioner.json + + +def test_practitioner_identifier_systems() -> None: + """ + Test that the correct identifier systems are used in the JSON output. + """ + practitioner = Practitioner( + id="10027", + sds_userid="999000111222", + role_profile_id="222333444555", + userid_url="https://test.com/user", + userid_value="test-guid-8", + family_name="Anderson", + ) + + json_output = practitioner.json + + # Verify the correct system URLs are used + assert "https://fhir.nhs.uk/Id/sds-user-id" in json_output + assert "https://fhir.nhs.uk/Id/sds-role-profile-id" in json_output diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 65e3c779..8ef4c2d9 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -6,7 +6,8 @@ import pytest import requests -from fhir import Bundle, OperationOutcome, Parameters, Patient +from fhir import Bundle, OperationOutcome, Patient +from fhir.parameters import Parameters from flask import Request from requests.structures import CaseInsensitiveDict from werkzeug.test import EnvironBuilder diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 1cda4a94..78568d0c 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,6 +2,7 @@ Controller layer for orchestrating calls to external services """ +from gateway_api.clinical_jwt import JWT, Device, Practitioner from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( NoAsidFoundError, @@ -57,11 +58,14 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: request.ods_from.strip(), provider_ods ) + token = self.get_jwt_for_provider(provider_endpoint, request.ods_from.strip()) + # Call GP provider with correct parameters self.gp_provider_client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) response = self.gp_provider_client.access_structured_record( @@ -84,6 +88,47 @@ def get_auth_token(self) -> str: """ return "AUTH_TOKEN123" + def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT: + # For requesting device details, see: + # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_device-claim + # For requesting practitioner details, see: + # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_practitioner-claim + + # TODO: Get requesting device details from consumer, somehow? + requesting_device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + # TODO: Get practitioner details from consumer, somehow? + requesting_practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + # TODO: Get consumer URL for issuer. Use CDG API URL for now. + issuer = "https://clinical-data-gateway-api.sandbox.nhs.uk" + audience = provider_endpoint + requesting_organization = consumer_ods + + token = JWT( + issuer=issuer, + subject=requesting_practitioner.id, + audience=audience, + requesting_device=requesting_device.json, + requesting_organization=requesting_organization, + requesting_practitioner=requesting_practitioner.json, + ) + return token + def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 94e8a23d..7e723e7c 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -9,15 +9,15 @@ from gateway_api.common.common import FlaskResponse from gateway_api.common.error import InvalidRequestJSONError, MissingOrEmptyHeaderError -if TYPE_CHECKING: - from fhir.bundle import Bundle - # Access record structured interaction ID from # https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ) +if TYPE_CHECKING: + from fhir.bundle import Bundle + class GetStructuredRecordRequest: INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 0bee89fa..87ec279b 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -21,7 +21,7 @@ import os import uuid from collections.abc import Callable -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from typing import cast import requests @@ -230,7 +230,7 @@ def find_current_gp( today: date | None = None, ) -> GeneralPractitioner | None: if today is None: - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() if self.ignore_dates: if len(general_practitioners) > 0: @@ -252,7 +252,7 @@ def find_current_name_record( self, names: list[HumanName], today: date | None = None ) -> HumanName | None: if today is None: - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() if self.ignore_dates: if len(names) > 0: diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 36be04e1..4b6c7e53 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -27,6 +27,7 @@ from requests import HTTPError, Response +from gateway_api.clinical_jwt import JWT from gateway_api.common.error import ProviderRequestFailedError from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -59,6 +60,7 @@ class GpProviderClient: provider_endpoint (str): The FHIR API endpoint for the provider. provider_asid (str): The ASID for the provider. consumer_asid (str): The ASID for the consumer. + token (JWT): JWT object for authentication with the provider API. Methods: access_structured_record(trace_id: str, body: str) -> Response: @@ -66,19 +68,18 @@ class GpProviderClient: """ def __init__( - self, - provider_endpoint: str, - provider_asid: str, - consumer_asid: str, + self, provider_endpoint: str, provider_asid: str, consumer_asid: str, token: JWT ) -> None: self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid self.consumer_asid = consumer_asid + self.token = token def _build_headers(self, trace_id: str) -> dict[str, str]: """ Build the headers required for the GPProvider FHIR API request. """ + # TODO: Post-steel-thread, probably check whether JWT is valid/not expired return { "Content-Type": "application/fhir+json", "Accept": "application/fhir+json", @@ -86,6 +87,7 @@ def _build_headers(self, trace_id: str) -> dict[str, str]: "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, "Ssp-TraceID": trace_id, + "Authorization": f"Bearer {self.token}", } def access_structured_record( diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 98b4a118..07d4e587 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -15,6 +15,7 @@ from requests.structures import CaseInsensitiveDict from stubs.provider.stub import GpProviderStub +from gateway_api.clinical_jwt import JWT from gateway_api.common.error import ProviderRequestFailedError from gateway_api.provider import GpProviderClient, client @@ -58,9 +59,22 @@ def _fake_post( return capture +@pytest.fixture +def dummy_jwt() -> JWT: + return JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device='{"device": "info"}', + requesting_organization="ORG-123", + requesting_practitioner='{"practitioner": "info"}', + ) + + def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method constructs the correct URL @@ -78,6 +92,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) result = client.access_structured_record( @@ -96,6 +111,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method includes the correct headers @@ -114,6 +130,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) expected_headers = { "Content-Type": "application/fhir+json", @@ -124,6 +141,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-InteractionID": ( "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ), + "Authorization": f"Bearer {dummy_jwt}", } result = client.access_structured_record( @@ -139,6 +157,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method includes the correct body @@ -158,6 +177,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) result = client.access_structured_record(trace_id, request_body) @@ -172,6 +192,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method returns the same response @@ -189,6 +210,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) expected_response = stub.access_record_structured( @@ -205,6 +227,8 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) + valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method raises an `SdsRequestFailed` @@ -219,10 +243,45 @@ def test_access_structured_record_raises_external_service_error( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) with pytest.raises( ProviderRequestFailedError, match="Provider request failed: Bad Request", ): - client.access_structured_record(trace_id, "body") + client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) + + +def test_gpprovider_client_includes_authorization_header_with_bearer_token( + mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, + dummy_jwt: JWT, +) -> None: + """ + Test that the GpProviderClient includes an Authorization header with the + Bearer token. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "test-trace-id" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + token=dummy_jwt, + ) + + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) + + captured_headers = mock_request_post["headers"] + + assert "Authorization" in captured_headers + assert captured_headers["Authorization"] == f"Bearer {dummy_jwt}" + assert result.status_code == 200 diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index bd282cb4..de7982f9 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -21,7 +21,7 @@ @pytest.fixture -def client() -> Generator[FlaskClient[Flask], None, None]: +def client() -> Generator[FlaskClient[Flask]]: app.config["TESTING"] = True with app.test_client() as client: yield client diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index fc783205..257a7d95 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -280,3 +280,73 @@ def mock_happy_path_get_structured_record_request( body=valid_simple_request_payload, ) return happy_path_request + + +def test_controller_creates_jwt_token_with_correct_claims( + mocker: MockerFixture, + valid_simple_request_payload: Parameters, + valid_simple_response_payload: Bundle, +) -> None: + """ + Test that the controller creates a JWT token with the correct claims. + """ + nhs_number = "9000000009" + provider_ods = "PROVIDER" + consumer_ods = "CONSUMER" + provider_endpoint = "https://provider.example/ep" + + # Mock PDS to return provider ODS code + pds_search_result = PdsSearchResults( + given_names="Jane", + family_name="Smith", + nhs_number=nhs_number, + gp_ods_code=provider_ods, + ) + mocker.patch( + "gateway_api.pds.PdsClient.search_patient_by_nhs_number", + return_value=pds_search_result, + ) + + # Mock SDS to return provider and consumer details + provider_sds_results = SdsSearchResults( + asid="asid_PROV", endpoint=provider_endpoint + ) + consumer_sds_results = SdsSearchResults(asid="asid_CONS", endpoint=None) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[provider_sds_results, consumer_sds_results], + ) + + # Mock GpProviderClient to capture initialization arguments + mock_gp_provider = mocker.patch("gateway_api.controller.GpProviderClient") + + # Mock the access_structured_record method to return a response + provider_response = FakeResponse( + status_code=200, + headers={"Content-Type": "application/fhir+json"}, + _json=valid_simple_response_payload, + ) + mock_gp_provider.return_value.access_structured_record.return_value = ( + provider_response + ) + + # Create request and run controller + request = create_mock_request( + headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, + body=valid_simple_request_payload, + ) + + controller = Controller() + _ = controller.run(GetStructuredRecordRequest(request)) + + # Verify that GpProviderClient was called and extract the JWT token + mock_gp_provider.assert_called_once() + jwt_token = mock_gp_provider.call_args.kwargs["token"] + + # Verify the standard JWT claims + assert jwt_token.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" + assert jwt_token.subject == "10019" + assert jwt_token.audience == provider_endpoint + + # Verify the requesting organization matches the consumer ODS + assert jwt_token.requesting_organization == consumer_ods diff --git a/gateway-api/stubs/stubs/pds/stub.py b/gateway-api/stubs/stubs/pds/stub.py index 23e36d7b..ed88b446 100644 --- a/gateway-api/stubs/stubs/pds/stub.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -6,7 +6,7 @@ import re import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from requests import Response @@ -239,10 +239,7 @@ def _now_fhir_instant() -> str: :return: Timestamp string in the format ``YYYY-MM-DDTHH:MM:SSZ``. """ return ( - datetime.now(timezone.utc) - .replace(microsecond=0) - .isoformat() - .replace("+00:00", "Z") + datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") ) @staticmethod diff --git a/gateway-api/stubs/stubs/provider/stub.py b/gateway-api/stubs/stubs/provider/stub.py index 0e157505..a1f8d58d 100644 --- a/gateway-api/stubs/stubs/provider/stub.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -70,7 +70,7 @@ def access_record_structured( try: nhs_number = json.loads(body)["parameter"][0]["valueIdentifier"]["value"] - except (json.JSONDecodeError, KeyError, IndexError): + except json.JSONDecodeError, KeyError, IndexError: return self._create_response( status_code=400, json_data={ diff --git a/gateway-api/tests/contract/conftest.py b/gateway-api/tests/contract/conftest.py index 49df8670..d451f3e5 100644 --- a/gateway-api/tests/contract/conftest.py +++ b/gateway-api/tests/contract/conftest.py @@ -76,7 +76,7 @@ def do_PUT(self) -> None: @pytest.fixture(scope="module") -def mtls_proxy(base_url: str) -> Generator[str, None, None]: +def mtls_proxy(base_url: str) -> Generator[str]: """ Spins up a local HTTP server in a separate thread. Returns the URL of this local proxy. diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py index 04b8fc84..b241f432 100644 --- a/gateway-api/tests/integration/test_sds_search.py +++ b/gateway-api/tests/integration/test_sds_search.py @@ -2,51 +2,100 @@ from __future__ import annotations -from gateway_api.sds import SdsClient +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tests.conftest import Client class TestSdsIntegration: """Integration tests for SDS search operations.""" - def test_get_device_by_ods_code_returns_valid_asid(self) -> None: + def test_get_device_by_ods_code_returns_valid_asid(self, client: Client) -> None: """ Test that querying by ODS code returns a valid ASID. - - :param sds_client: SDS client fixture configured with stub. """ - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="PROVIDER") + # Create a request payload with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } + + # Make request to the application endpoint + response = client.send_to_get_structured_record_endpoint(json.dumps(payload)) - assert result is not None - assert result.asid == "asid_PROV" - assert result.endpoint == "https://provider.example.com/fhir" + # Verify successful response indicates SDS lookup worked + assert response.status_code == 200 + # Verify we got a FHIR response (which means the full flow including SDS worked) + response_data = response.json() + assert response_data.get("resourceType") == "Bundle" - def test_consumer_organization_lookup(self) -> None: + def test_consumer_organization_lookup(self, client: Client) -> None: """ Test that CONSUMER organization can be looked up successfully. - - :param sds_client: SDS client fixture configured with stub. """ - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="CONSUMER") + # Create a request with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } + + # Use A12345 as the consumer ODS (Ods-from header) + response = client.send_to_get_structured_record_endpoint( + json.dumps(payload), + headers={"Ods-from": "A12345"}, # Consumer ODS code + ) - assert result is not None - assert result.asid == "asid_CONS" - assert result.endpoint == "https://consumer.example.com/fhir" + # Verify successful response indicates both consumer and provider + # SDS lookups worked + assert response.status_code == 200 + response_data = response.json() + assert response_data.get("resourceType") == "Bundle" - def test_result_contains_both_asid_and_endpoint_when_available(self) -> None: + def test_result_contains_both_asid_and_endpoint_when_available( + self, client: Client + ) -> None: """ Test that results contain both ASID and endpoint when both are available. - - :param sds_client: SDS client fixture configured with stub. """ + # Create a request with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="PROVIDER") + # Make request to the application endpoint + response = client.send_to_get_structured_record_endpoint(json.dumps(payload)) - assert result is not None - # Verify both fields are present and not None - assert hasattr(result, "asid") - assert hasattr(result, "endpoint") - assert result.asid is not None - assert result.endpoint is not None + # Verify successful response (200) means both ASID and endpoint were retrieved + # If either were missing, the application would fail with an error + assert response.status_code == 200 + response_data = response.json() + # Verify we got a valid FHIR Bundle (indicating full flow including SDS worked) + assert response_data.get("resourceType") == "Bundle" + assert "entry" in response_data or response_data.get("total") is not None