Skip to content

Commit 63b4f84

Browse files
committed
[GPCAPIM-395]: Initial implementation of PDS INT integration in runtime
1 parent e156430 commit 63b4f84

17 files changed

Lines changed: 1418 additions & 3 deletions

File tree

.github/instructions/copilot-instructions.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ When reviewing code, ensure you compare the changes made to files to all README.
5050

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

53+
## Branches
54+
55+
When creating a branch for a Jira ticket, use:
56+
57+
`feature/<JIRA_TICKET>_<Short_description>`
58+
59+
Example: `feature/GPCAPIM-395_Local_PDS_INT_Integration`
60+
5361
## Security
5462

5563
This repository is public. Do not commit any secrets, tokens or credentials.
64+
65+
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).
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
name: local - PDS_INT
2+
variables:
3+
- name: base_url
4+
value: http://localhost:5000
5+
- name: nhs_number
6+
value: "9692140466"
7+
- name: from_ods
8+
value: S55555
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""APIM App Restricted auth tooling."""
2+
3+
from gateway_api.apim_app_auth.apim import (
4+
ApimAuthenticationException,
5+
ApimAuthenticator,
6+
)
7+
8+
__all__ = [
9+
"ApimAuthenticationException",
10+
"ApimAuthenticator",
11+
]
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import functools
2+
import logging
3+
import uuid
4+
from collections.abc import Callable
5+
from datetime import datetime, timedelta, timezone
6+
from typing import Any, TypedDict
7+
8+
import jwt
9+
import requests
10+
11+
from gateway_api.apim_app_auth.http import RequestMethod, SessionManager
12+
13+
_logger = logging.getLogger(__name__)
14+
15+
16+
class ApimAuthenticationException(Exception):
17+
pass
18+
19+
20+
class ApimAuthenticator:
21+
class __AccessToken(TypedDict):
22+
value: str
23+
expiry: datetime
24+
25+
def __init__(
26+
self,
27+
private_key: str,
28+
key_id: str,
29+
api_key: str,
30+
token_validity_threshold: timedelta,
31+
token_endpoint: str,
32+
session_manager: SessionManager,
33+
):
34+
self._private_key = private_key
35+
self._key_id = key_id
36+
self._api_key = api_key
37+
self._token_validity_threshold = token_validity_threshold
38+
self._token_endpoint = token_endpoint
39+
self._session_manager = session_manager
40+
41+
self._access_token: ApimAuthenticator.__AccessToken | None = None
42+
43+
def auth[**P, S](self, func: RequestMethod[P, S]) -> Callable[P, S]:
44+
"""
45+
Decorate a given function with APIM authentication. This authentication will be
46+
provided via a `requests.Session` object.
47+
"""
48+
49+
@functools.wraps(func)
50+
def wrapper(*args: Any, **kwargs: Any) -> Any:
51+
@self._session_manager.with_session
52+
def with_session(
53+
session: requests.Session, access_token: ApimAuthenticator.__AccessToken
54+
) -> S:
55+
session.headers.update(
56+
{"Authorization": f"Bearer {access_token['value']}"}
57+
)
58+
return func(session, *args, **kwargs)
59+
60+
# If there isn't an access token yet, or the token will expire within the
61+
# token validity threshold, reauthenticate.
62+
if (
63+
self._access_token is None
64+
or self._access_token["expiry"] - datetime.now(tz=timezone.utc)
65+
< self._token_validity_threshold
66+
):
67+
_logger.debug("Authenticating with APIM...")
68+
self._access_token = self._authenticate()
69+
70+
return with_session(self._access_token)
71+
72+
return wrapper
73+
74+
def _create_client_assertion(self) -> str:
75+
_logger.debug("Creating client assertion JWT for APIM authentication")
76+
claims = {
77+
"sub": self._api_key,
78+
"iss": self._api_key,
79+
"jti": str(uuid.uuid4()),
80+
"aud": self._token_endpoint,
81+
"exp": int(
82+
(datetime.now(tz=timezone.utc) + timedelta(seconds=30)).timestamp()
83+
),
84+
}
85+
_logger.debug(
86+
"Created client claims. jti: %s, exp: %s, aud: %s",
87+
claims["jti"],
88+
claims["exp"],
89+
claims["aud"],
90+
)
91+
92+
client_assertion = jwt.encode(
93+
claims,
94+
self._private_key,
95+
algorithm="RS512",
96+
headers={"kid": self._key_id},
97+
)
98+
99+
_logger.debug("Created client assertion. kid: %s", self._key_id)
100+
101+
return client_assertion
102+
103+
def _authenticate(self) -> __AccessToken:
104+
@self._session_manager.with_session
105+
def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken:
106+
client_assertion = self._create_client_assertion()
107+
108+
_logger.debug("Sending token request with created session.")
109+
110+
response = session.post(
111+
self._token_endpoint,
112+
data={
113+
"grant_type": "client_credentials",
114+
"client_assertion_type": "urn:ietf:params:oauth"
115+
":client-assertion-type:jwt-bearer",
116+
"client_assertion": client_assertion,
117+
},
118+
)
119+
120+
_logger.debug(
121+
"Response received from APIM token endpoint. Status code: %s",
122+
response.status_code,
123+
)
124+
125+
if response.status_code != 200:
126+
raise ApimAuthenticationException(
127+
f"Failed to authenticate with APIM. "
128+
f"Status code: {response.status_code}"
129+
f", Response: {response.text}"
130+
)
131+
132+
response_data = response.json()
133+
_logger.debug(
134+
"APIM authentication successful. Expiry: %s",
135+
response_data["expires_in"],
136+
)
137+
138+
return {
139+
"value": response_data["access_token"],
140+
"expiry": datetime.now(tz=timezone.utc)
141+
+ timedelta(seconds=int(response_data["expires_in"])),
142+
}
143+
144+
_logger.debug(
145+
"Sending authentication request to APIM: %s", self._token_endpoint
146+
)
147+
return with_session()
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import os
2+
import re
3+
from collections.abc import Callable
4+
from dataclasses import dataclass
5+
from datetime import timedelta
6+
from enum import StrEnum
7+
from typing import Any, cast
8+
9+
10+
class ConfigError(Exception):
11+
pass
12+
13+
14+
class DurationUnit(StrEnum):
15+
SECONDS = "s"
16+
MINUTES = "m"
17+
18+
19+
@dataclass(frozen=True)
20+
class Duration:
21+
unit: DurationUnit
22+
value: int
23+
24+
@property
25+
def timedelta(self) -> timedelta:
26+
match self.unit:
27+
case DurationUnit.SECONDS:
28+
return timedelta(seconds=self.value)
29+
case DurationUnit.MINUTES:
30+
return timedelta(minutes=self.value)
31+
32+
33+
_SUPPORTED_PRIMITIVES: dict[type[Any], Callable[[str], Any]] = {
34+
str: str,
35+
int: int,
36+
}
37+
38+
39+
def get_optional_environment_variable[T](name: str, _type: type[T]) -> T | None:
40+
value = os.getenv(name)
41+
42+
match _type:
43+
case _ if _type is Duration:
44+
if value is None:
45+
return None
46+
47+
parsed = re.fullmatch(r"(?P<value>\d+)(?P<unit>[sm])", value)
48+
if parsed is None:
49+
raise ConfigError(f"Invalid duration value: {value!r}")
50+
51+
raw_value = parsed.group("value")
52+
raw_unit = parsed.group("unit")
53+
54+
return cast(
55+
"T",
56+
Duration(
57+
unit=DurationUnit(raw_unit),
58+
value=int(raw_value),
59+
),
60+
)
61+
62+
case _ if _type in _SUPPORTED_PRIMITIVES:
63+
if value is None:
64+
return None
65+
66+
return cast("T", _SUPPORTED_PRIMITIVES[_type](value))
67+
68+
case _:
69+
raise ValueError(
70+
f"Required type {_type} is not supported for config values"
71+
)
72+
73+
74+
def get_environment_variable[T](name: str, _type: type[T]) -> T:
75+
value = get_optional_environment_variable(name=name, _type=_type)
76+
if value is None:
77+
raise ConfigError(f"Environment variable {name!r} is not set")
78+
return value
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# TODO: 395 update this to align with the new environment
2+
# variable management approach once that is implemented
3+
4+
from typing import TypedDict
5+
6+
from gateway_api.apim_app_auth.apim import ApimAuthenticator
7+
from gateway_api.apim_app_auth.config import (
8+
Duration,
9+
get_environment_variable,
10+
)
11+
from gateway_api.apim_app_auth.http import SessionManager
12+
13+
__all__ = [
14+
"apim_authenticator",
15+
"values",
16+
"session_manager",
17+
]
18+
19+
20+
class Environment(TypedDict):
21+
client_timeout: Duration
22+
apim_token_url: str
23+
apim_private_key_name: str
24+
apim_api_key_name: str
25+
apim_token_expiry_threshold: Duration
26+
apim_key_id: str
27+
pdm_url: str
28+
mns_url: str
29+
30+
31+
_environment: Environment | None = None
32+
_session_manager: SessionManager | None = None
33+
_apim_authenticator: ApimAuthenticator | None = None
34+
35+
36+
def values() -> Environment:
37+
global _environment
38+
if _environment is None:
39+
_environment = Environment(
40+
client_timeout=get_environment_variable(
41+
"CLIENT_TIMEOUT",
42+
Duration,
43+
),
44+
apim_token_url=get_environment_variable(
45+
"APIM_TOKEN_URL",
46+
str,
47+
),
48+
apim_private_key_name=get_environment_variable(
49+
"APIM_PRIVATE_KEY_NAME",
50+
str,
51+
),
52+
apim_api_key_name=get_environment_variable(
53+
"APIM_API_KEY_NAME",
54+
str,
55+
),
56+
apim_token_expiry_threshold=get_environment_variable(
57+
"APIM_TOKEN_EXPIRY_THRESHOLD",
58+
Duration,
59+
),
60+
apim_key_id=get_environment_variable(
61+
"APIM_KEY_ID",
62+
str,
63+
),
64+
pdm_url=get_environment_variable(
65+
"PDM_BUNDLE_URL",
66+
str,
67+
),
68+
mns_url=get_environment_variable(
69+
"MNS_EVENT_URL",
70+
str,
71+
),
72+
)
73+
74+
return _environment
75+
76+
77+
def session_manager() -> SessionManager:
78+
global _session_manager
79+
80+
if _session_manager is None:
81+
client_certificate = None
82+
83+
_session_manager = SessionManager(
84+
client_timeout=get_environment_variable(
85+
"CLIENT_TIMEOUT", Duration
86+
).timedelta,
87+
client_certificate=client_certificate,
88+
)
89+
90+
return _session_manager
91+
92+
93+
def apim_authenticator() -> ApimAuthenticator:
94+
global _apim_authenticator
95+
96+
if _apim_authenticator is None:
97+
env = values()
98+
_apim_authenticator = ApimAuthenticator(
99+
private_key="", # TODO: 395 get private key
100+
key_id=env["apim_key_id"],
101+
api_key="", # TODO: 395 get api key
102+
token_endpoint=env["apim_token_url"],
103+
token_validity_threshold=env["apim_token_expiry_threshold"].timedelta,
104+
session_manager=session_manager(),
105+
)
106+
107+
return _apim_authenticator

0 commit comments

Comments
 (0)