Skip to content

Commit 09469fb

Browse files
[CDAPI-85]: Initial introduction of APIM Authenticator class
Initial Introduction of the ApimAuthenticator class, handling authentication with the API Management platform utilising Signed JWT application restricted access. This commit also includes the creation of a `SessionManager` class handling the creation of a `request.Session` object with appropriate default configuration.
1 parent 0b3a267 commit 09469fb

File tree

4 files changed

+270
-7
lines changed

4 files changed

+270
-7
lines changed

pathology-api/poetry.lock

Lines changed: 100 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pathology-api/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ readme = "README.md"
99
requires-python = ">3.13,<4.0.0"
1010
dependencies = [
1111
"aws-lambda-powertools (>=3.24.0,<4.0.0)",
12-
"pydantic (>=2.12.5,<3.0.0)"
12+
"pydantic (>=2.12.5,<3.0.0)",
13+
"pyjwt[crypto] (>=2.11.0,<3.0.0)"
1314
]
1415

1516
[tool.poetry]
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import uuid
2+
from collections.abc import Callable
3+
from datetime import datetime, timedelta, timezone
4+
from typing import Any, Concatenate, TypedDict
5+
6+
import jwt
7+
import requests
8+
9+
from pathology_api.http import SessionManager
10+
11+
12+
class ApimAuthenticationException(Exception):
13+
pass
14+
15+
16+
# Type alias describing the expected signature for use with the `Authenticator.auth`
17+
# decorator.
18+
# Any function that takes a `requests.Session` as its first argument, followed by any
19+
# number of additional arguments, and returns any type of value.
20+
type AuthenticatedMethod[**P] = Callable[Concatenate[requests.Session, P], Any]
21+
22+
23+
class ApimAuthenticator:
24+
class __AccessToken(TypedDict):
25+
value: str
26+
expiry: datetime
27+
28+
def __init__(
29+
self,
30+
private_key: str,
31+
key_id: str,
32+
api_key: str,
33+
token_validity_threshold: timedelta,
34+
token_endpoint: str,
35+
session_manager: SessionManager,
36+
):
37+
self._private_key = private_key
38+
self._key_id = key_id
39+
self._api_key = api_key
40+
self._token_validity_threshold = token_validity_threshold
41+
self._token_endpoint = token_endpoint
42+
self._session_manager = session_manager
43+
44+
self.__access_token: ApimAuthenticator.__AccessToken | None = None
45+
46+
def auth[**P](
47+
self, func: AuthenticatedMethod[P]
48+
) -> Callable[[AuthenticatedMethod[P]], AuthenticatedMethod[P]]:
49+
"""
50+
Decorate a given function with APIM authentication. This authentication will be
51+
provided via a `requests.Session` object.
52+
"""
53+
54+
def wrapper(*args: Any, **kwargs: Any) -> Any:
55+
# If there isn't an access token yet, or the token will expire within the
56+
# token validity threshold, reauthenticate.
57+
if (
58+
self.__access_token is None
59+
or self.__access_token["expiry"] - datetime.now(tz=timezone.utc)
60+
< self._token_validity_threshold
61+
):
62+
self.__access_token = self._authenticate()
63+
64+
with self._session_manager.open_session() as session:
65+
session.headers.update(
66+
{"Authorization": f"Bearer {self.__access_token['value']}"}
67+
)
68+
return func(session, *args, **kwargs)
69+
70+
return wrapper
71+
72+
def _create_client_assertion(self) -> str:
73+
claims = {
74+
"sub": self._api_key,
75+
"iss": self._api_key,
76+
"jti": str(uuid.uuid4()),
77+
"aud": self._token_endpoint,
78+
"exp": int(
79+
(datetime.now(tz=timezone.utc) + timedelta(seconds=30)).timestamp()
80+
),
81+
}
82+
83+
return jwt.encode(
84+
claims,
85+
self._private_key,
86+
algorithm="RS512",
87+
headers={"kid": self._key_id},
88+
)
89+
90+
def _authenticate(self) -> __AccessToken:
91+
with self._session_manager.open_session() as session:
92+
response = session.post(
93+
self._token_endpoint,
94+
data={
95+
"grant_type": "client_credentials",
96+
"client_assertion_type": "urn:ietf:params:oauth"
97+
":client-assertion-type:jwt-bearer",
98+
"client_assertion": self._create_client_assertion(),
99+
},
100+
)
101+
102+
if response.status_code != 200:
103+
raise ApimAuthenticationException(
104+
f"Failed to authenticate with APIM. "
105+
f"Status code: {response.status_code}"
106+
f", Response: {response.text}"
107+
)
108+
109+
response_data = response.json()
110+
111+
return {
112+
"value": response_data["access_token"],
113+
"expiry": datetime.now(tz=timezone.utc)
114+
+ timedelta(seconds=response_data["expires_in"]),
115+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from datetime import timedelta
2+
from typing import Any, TypedDict
3+
4+
import requests
5+
from requests.adapters import HTTPAdapter
6+
7+
8+
class ClientCertificate(TypedDict):
9+
certificate_path: str
10+
key_path: str
11+
12+
13+
class SessionManager:
14+
class _Adapter(HTTPAdapter):
15+
"""
16+
HTTPAdapter to apply default configuration to apply to all created
17+
`request.Session` objects.
18+
"""
19+
20+
def __init__(self, timeout: float):
21+
self._timeout = timeout
22+
super().__init__()
23+
24+
def send(
25+
self,
26+
request: requests.PreparedRequest,
27+
*args: Any,
28+
**kwargs: Any,
29+
) -> requests.Response:
30+
kwargs["timeout"] = self._timeout
31+
return super().send(request, *args, **kwargs)
32+
33+
def __init__(
34+
self,
35+
client_timeout: timedelta,
36+
client_certificate: ClientCertificate | None = None,
37+
):
38+
self._client_adapter = self._Adapter(timeout=client_timeout.total_seconds())
39+
self._client_certificate = client_certificate
40+
41+
def open_session(self) -> requests.Session:
42+
session = requests.Session()
43+
44+
if self._client_certificate is not None:
45+
session.cert = (
46+
self._client_certificate["certificate_path"],
47+
self._client_certificate["key_path"],
48+
)
49+
50+
session.mount("https://", self._client_adapter)
51+
session.mount("http://", self._client_adapter)
52+
53+
return session

0 commit comments

Comments
 (0)