Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
63b4f84
[GPCAPIM-395]: Initial implementation of PDS INT integration in runtime
DWolfsNHS Apr 22, 2026
3e0efc2
[GPCAPIM-395]: gets an invalid token
DWolfsNHS Apr 27, 2026
6c5a6b8
[GPCAPIM-395]: Refactor PDS integration to remove auth token
DWolfsNHS Apr 27, 2026
5b61536
[GPCAPIM-395]: Add test data for PDS_INT integration
DWolfsNHS Apr 27, 2026
c4e3779
[GPCAPIM-395]: Add test patient to provider stub
DWolfsNHS Apr 27, 2026
c23ddbd
[GPCAPIM-395]: Refactor session manager tests and clean up environmen…
DWolfsNHS Apr 27, 2026
6c4b853
[GPCAPIM-395]: Update patch decorators for session manager and JWT en…
DWolfsNHS Apr 27, 2026
4d4f892
Merge branch 'main' into feature/GPCAPIM-395_Local_PDS_INT_Integration
DWolfsNHS Apr 27, 2026
8a332fd
[GPCAPIM-395]: Update environment variable management
DWolfsNHS Apr 28, 2026
356786f
[GPCAPIM-395]: Enhance environment variable management
DWolfsNHS Apr 28, 2026
4461bad
[GPCAPIM-395]: Update PDS integration environment handling
DWolfsNHS Apr 28, 2026
36116da
[GPCAPIM-395]: Integrate APIM token URL retrieval
DWolfsNHS Apr 28, 2026
b9fdfff
[GPCAPIM-395]: Refactor PDS client tests to remove auth token dependency
DWolfsNHS Apr 28, 2026
95759f6
[GPCAPIM-395]: Update PDS URL handling in controller tests
DWolfsNHS Apr 28, 2026
8db7193
[GPCAPIM-395]: Update PDS API secret handling
DWolfsNHS Apr 28, 2026
a65d561
[GPCAPIM-395]: Update client timeout and expiry threshold
DWolfsNHS Apr 28, 2026
70a5cf1
[GPCAPIM-395]: Reset environment variable before tests
DWolfsNHS Apr 28, 2026
ba3044b
[GPCAPIM-395]: Reload environment in setup method
DWolfsNHS Apr 28, 2026
2b6f4cd
[GPCAPIM-395]: Add PDS URL for testing environment
DWolfsNHS Apr 28, 2026
9c253ed
[GPCAPIM-395]: Update environment variables for testing
DWolfsNHS Apr 28, 2026
eebb3f4
[GPCAPIM-395]: Remove TODO comments
DWolfsNHS Apr 28, 2026
320e7bd
[GPCAPIM-395]: Update PDS and SDS URLs to use HTTPS
DWolfsNHS Apr 28, 2026
b8e1142
[GPCAPIM-395]: Preliminary implementation of in-memory APIM APP Auth …
DWolfsNHS Apr 28, 2026
90fc45b
[GPCAPIM-395]: pre-empting PR197
DWolfsNHS Apr 30, 2026
63ef5ec
[GPCAPIM-395]: Implement APIM App Auth stub and update authentication…
DWolfsNHS Apr 30, 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
10 changes: 10 additions & 0 deletions .github/instructions/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ When reviewing code, ensure you compare the changes made to files to all README.

Prepend `[AI-generated]` to the commit message when committing changes made by an AI agent.

## Branches

When creating a branch for a Jira ticket, use:

`feature/<JIRA_TICKET>_<Short_description>`

Example: `feature/GPCAPIM-395_Local_PDS_INT_Integration`

## Security

This repository is public. Do not commit any secrets, tokens or credentials.

Do not bypass file access restrictions in any way (for example, by using terminal commands to read files that Copilot tooling cannot access, such as `.env` or other local secret files).
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,15 @@ Environment variables control whether stubs are used in place of the real PDS, S

| Variable | Description |
| --- | --- |
| `APIM_TOKEN_URL` | The URL for the APIM authentication |
| `APIM_TOKEN_EXPIRY_THRESHOLD` | The duration which an API auth will last for |
| `CLIENT_TIMEOUT` | Timeout used for the APIM auth request |
| `PDS_URL` | The URL for the PDS FHIR API; set as `stub` to use development stub. |
| `PDS_API_TOKEN`| Leave unset in development environment. |
| `PDS_API_SECRET`| Leave unset in development environment. |
| `PDS_API_KID`| Leave unset in development environment. |
| `PDS_API_TOKEN` | Leave unset in development environment. |
| `PDS_API_SECRET` | Leave unset in development environment. |
| `PDS_API_KID` | Leave unset in development environment. |
| `SDS_URL` | The URL for the SDS FHIR API; set as `stub` to use development stub. |
| `SDS_API_TOKEN`| Leave unset in development environment. |
| `SDS_API_TOKEN` | Leave unset in development environment. |
| `PROVIDER_URL` | The URL for the GP Provider; set as `stub` to use development stub. |
| `CDG_DEBUG` | `true`, return additional debug information when the call to the GP provider returns an error. |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: local - PDS_INT
variables:
- name: base_url
value: http://localhost:5000
- name: nhs_number
value: "9692140466"
- name: from_ods
value: S55555
34 changes: 11 additions & 23 deletions gateway-api/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions gateway-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ readme = "README.md"
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.3"
types-flask = "^1.1.6"
requests = "^2.33.0"
pyjwt = "^2.12.0"
pyjwt = {version = "^2.12.0", extras = ["crypto"]}
pydantic = "^2.0"

[tool.poetry]
Expand Down
11 changes: 11 additions & 0 deletions gateway-api/src/gateway_api/apim_app_auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""APIM App Restricted auth tooling."""

from gateway_api.apim_app_auth.apim import (
ApimAuthenticationException,
ApimAuthenticator,
)

__all__ = [
"ApimAuthenticationException",
"ApimAuthenticator",
]
173 changes: 173 additions & 0 deletions gateway-api/src/gateway_api/apim_app_auth/apim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import functools
import logging
import os
import uuid
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import Any, TypedDict

import jwt
import requests
from stubs.apim_app_auth.stub import APIMAppAuthStub

from gateway_api.apim_app_auth.http import RequestMethod, SessionManager

_logger = logging.getLogger(__name__)


# TODO [GPCAPIM-359]: change this to a new STUB_APIM_APP_AUTH env var
_pds_url = os.getenv("APIM_TOKEN_URL", "not stub")
STUB_APIM_APP_AUTH = _pds_url.strip().lower() == "stub"


def _make_session_post(
session: requests.Session, endpoint: str, data: dict[str, str]
) -> requests.Response:
print("DaveW: in _make_session_post STUB_APIM_APP_AUTH", STUB_APIM_APP_AUTH)
if not STUB_APIM_APP_AUTH:
print("DaveW: in if ", session.post)
return session.post(endpoint, data=data)
else:
stub = APIMAppAuthStub()
print("Dave W: in _make_session_post, stub:", stub.session_post)
print(
"Dave W: in _make_session_post, session_post:",
stub.session_post(session, endpoint, data),
)
response = stub.session_post(session, endpoint, data)
return response


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"],
)

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

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.")

response = _make_session_post(
session,
self._token_endpoint,
data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth"
":client-assertion-type:jwt-bearer",
"client_assertion": client_assertion,
},
)

_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()
Loading
Loading