pytest-nhsd-apim 3.3.6__py3-none-any.whl → 5.0.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,8 @@ import jwt
5
5
  import pyotp
6
6
  import requests
7
7
  from jwt import ExpiredSignatureError
8
- from pydantic import BaseSettings, root_validator
8
+ from pydantic import model_validator
9
+ from pydantic_settings import BaseSettings
9
10
 
10
11
 
11
12
  class ApigeeProdCredentials(BaseSettings):
@@ -38,29 +39,27 @@ class ApigeeProdCredentials(BaseSettings):
38
39
  apigee_nhsd_prod_password: Optional[str] = None
39
40
  apigee_nhsd_prod_passcode: Optional[str] = None
40
41
  apigee_access_token: Optional[str] = None
42
+ auth_method: Optional[str] = None
41
43
 
42
- @root_validator(pre=True)
44
+ @model_validator(mode="before")
43
45
  def check_credentials_config(cls, values):
44
46
  print(values)
45
47
  """Checks for the right set of credentials"""
46
48
  if all(
47
49
  [
48
- values.get(key)
49
- for key in [
50
- "apigee_nhsd_prod_username",
51
- "apigee_nhsd_prod_password",
52
- "auth_server",
53
- ]
50
+ values.get("apigee_nhsd_prod_username"),
51
+ values.get("apigee_nhsd_prod_password"),
52
+ values.get("auth_server"),
54
53
  ]
55
54
  ):
56
55
  values["auth_method"] = "saml"
57
56
  return values
58
57
  elif all(
59
- [values.get(key) for key in ["auth_server", "apigee_nhsd_prod_passcode"]]
58
+ [values.get("auth_server"), values.get("apigee_nhsd_prod_passcode")]
60
59
  ):
61
60
  values["auth_method"] = "saml"
62
61
  return values
63
- elif values["access_token"]:
62
+ elif values.get("apigee_access_token"):
64
63
  values["auth_method"] = "access_token"
65
64
  return values
66
65
  else:
@@ -101,39 +100,34 @@ class ApigeeProdCredentials(BaseSettings):
101
100
 
102
101
  class ApigeeNonProdCredentials(BaseSettings):
103
102
  auth_server: str = "login.apigee.com"
104
- apigee_nhsd_nonprod_username: Optional[str]
105
- apigee_nhsd_nonprod_password: Optional[str]
106
- apigee_nhsd_nonprod_otp_key: Optional[str]
107
- apigee_access_token: Optional[str]
103
+ apigee_nhsd_nonprod_username: Optional[str] = None
104
+ apigee_nhsd_nonprod_password: Optional[str] = None
105
+ apigee_nhsd_nonprod_otp_key: Optional[str] = None
106
+ apigee_access_token: Optional[str] = None
107
+ auth_method: Optional[str] = None
108
108
 
109
- @root_validator
109
+ @model_validator(mode='before')
110
110
  def check_credentials_config(cls, values):
111
111
  """Checks for the right set of credentials"""
112
112
  if all(
113
113
  [
114
- values.get(key)
115
- for key in [
116
- "apigee_nhsd_nonprod_username",
117
- "apigee_nhsd_nonprod_password",
118
- "apigee_nhsd_nonprod_otp_key",
119
- ]
114
+ values.get("apigee_nhsd_nonprod_username"),
115
+ values.get("apigee_nhsd_nonprod_password"),
116
+ values.get("apigee_nhsd_nonprod_otp_key"),
120
117
  ]
121
118
  ):
122
119
  values["auth_method"] = "saml"
123
120
  return values
124
121
  elif all(
125
122
  [
126
- values.get(key)
127
- for key in [
128
- "auth_server",
129
- "apigee_nhsd_nonprod_password",
130
- "apigee_nhsd_nonprod_username",
131
- ]
123
+ values.get("auth_server"),
124
+ values.get("apigee_nhsd_nonprod_password"),
125
+ values.get("apigee_nhsd_nonprod_username"),
132
126
  ]
133
127
  ):
134
128
  values["auth_method"] = "saml"
135
129
  return values
136
- elif values["apigee_access_token"]:
130
+ elif values.get("apigee_access_token"):
137
131
  values["auth_method"] = "access_token"
138
132
  return values
139
133
  else:
@@ -476,7 +470,7 @@ class DeveloperAppsAPI:
476
470
  resp = self.client.delete(url=url)
477
471
  if resp.status_code != 200:
478
472
  raise Exception(
479
- f"GET request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
473
+ f"DELETE request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
480
474
  )
481
475
  return resp.json()
482
476
 
@@ -1241,11 +1235,218 @@ class UserRolesAPI:
1241
1235
 
1242
1236
 
1243
1237
  class AppKeysAPI:
1238
+ """
1239
+ Manage consumer credentials for apps associated with individual developers.
1240
+
1241
+ Credential pairs consisting of consumer key and consumer secret are provisioned
1242
+ by Apigee Edge to apps for specific API products. Apigee Edge maintains the
1243
+ relationship between consumer keys and API products, enabling API products to be
1244
+ added to and removed from consumer keys. A single consumer key can be used to
1245
+ access multiple API products. Keys may be manually or automatically approved for
1246
+ API products--how they are issued depends on the API product configuration. A key
1247
+ must approved and approved for an API product to be capable of accessing any of
1248
+ the URIs defined in the API product.
1249
+ """
1250
+
1244
1251
  def __init__(self, client: RestClient) -> None:
1245
1252
  self.client = client
1246
- raise NotImplementedError(
1247
- f"Ugh! this is awkward, this API is not available yet...feel free to give us a shout or to open a PR https://github.com/NHSDigital/pytest-nhsd-apim/blob/0cf274850a8fe61e17f214380496ba09fd6cc973/src/pytest_nhsd_apim/apigee_apis.py#L1142"
1248
- )
1253
+
1254
+ def create_app_key(self, email: str, app_name: str, body: dict) -> "dict":
1255
+ """
1256
+ Creates a custom consumer key and secret for a developer app.
1257
+ This is particularly useful if you want to migrate existing consumer
1258
+ keys/secrets to Edge from another system.
1259
+
1260
+ After creating the consumer key and secret, associate the key with an
1261
+ API product, as described in Add API Product to Key.
1262
+
1263
+ Consumer keys and secrets can contain letters, numbers, underscores,
1264
+ and hyphens. No other special characters are allowed.
1265
+
1266
+ Note: Be aware of the following size limits on API keys. By staying
1267
+ within these limits, you help avoid service disruptions.
1268
+
1269
+ - Consumer key (API key) size: 2 KB
1270
+
1271
+ - Consumer secret size: 2 KB
1272
+
1273
+ If a consumer key and secret already exist, you can either keep them
1274
+ or delete them, as described in Delete Key for a Developer App.
1275
+
1276
+ In addition, you can use this API if you have existing API keys and
1277
+ secrets that you want to copy into Edge from another system. For more
1278
+ information, see Import existing consumer keys and secrets.
1279
+ """
1280
+
1281
+ resource = f"/developers/{email}/apps/{app_name}/keys/create"
1282
+ url = f"{self.client.base_url}{resource}"
1283
+ resp = self.client.post(url=url, json=body)
1284
+ if resp.status_code != 201:
1285
+ raise Exception(
1286
+ f"POST request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
1287
+ )
1288
+ return resp.json()
1289
+
1290
+ def delete_app_key(self, email: str, app_name: str, app_key: str) -> None:
1291
+ """
1292
+ Deletes a consumer key that belongs to an app, and removes all API products
1293
+ associated with the app. Once deleted, the consumer key cannot be used
1294
+ to access any APIs.
1295
+
1296
+ After you delete a consumer key, you may want to:
1297
+
1298
+ - Create a new consumer key and secret for the developer app, and
1299
+ subsequently add an API product to the key.
1300
+
1301
+ - Delete the developer app, if it is no longer required.
1302
+ """
1303
+
1304
+ resource = f"/developers/{email}/apps/{app_name}/keys/{app_key}"
1305
+ url = f"{self.client.base_url}{resource}"
1306
+ resp = self.client.delete(url=url)
1307
+ if resp.status_code != 200:
1308
+ raise Exception(
1309
+ f"DELETE request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
1310
+ )
1311
+ return resp.json()
1312
+
1313
+ def get_app_key(self, email: str, app_name: str, key: str, **query_params) -> "list[str]":
1314
+ """
1315
+ Gets details for a consumer key for a developer app, including the key
1316
+ and secret value, associated API products, and other information.
1317
+ """
1318
+
1319
+ params = query_params
1320
+ resource = f"/developers/{email}/apps/{app_name}/keys/{key}"
1321
+ url = f"{self.client.base_url}{resource}"
1322
+ resp = self.client.get(url=url, params=params)
1323
+ if resp.status_code != 200:
1324
+ raise Exception(
1325
+ f"GET request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
1326
+ )
1327
+ return resp.json()
1328
+
1329
+ def post_app_key(self, email: str, app_name: str, key: str, body: dict, **query_params) -> "dict":
1330
+ """
1331
+ Enables you to perform one of the following tasks:
1332
+
1333
+ - Add an API product to a developer app key, enabling the app that holds
1334
+ the key to access the API resources bundled in the API product. You can
1335
+ also use this API to add attributes to the key. You must include all existing
1336
+ attributes, whether or not you are updating them, as well as any new attributes
1337
+ that you are adding. After adding the API product, you can use the same key
1338
+ to access all API products associated with the app.
1339
+
1340
+ - Approve or revoke a specific consumer key for an app. Call the API with the
1341
+ action query parameter set to approve or revoke (with no request body) and
1342
+ set the Content-type header to application/octet-stream. If successful, the HTTP
1343
+ status code for success is: 204 No Content
1344
+
1345
+ - You can approve a consumer key that is currently revoked or pending. Once
1346
+ approved, the app can use the consumer key to access APIs. Revoking a consumer
1347
+ key renders it unusable for the app to use to access an API.
1348
+
1349
+ - Note: Any access tokens associated with a revoked app key will remain active.
1350
+ However, Apigee Edge checks the status of the app key and if set to revoked it
1351
+ will not allow API calls to go through.
1352
+ """
1353
+
1354
+ resource = f"/developers/{email}/apps/{app_name}/keys/{key}"
1355
+ url = f"{self.client.base_url}{resource}"
1356
+ resp = self.client.post(url=url, json=body, params=query_params)
1357
+ if resp.status_code != 200 and resp.status_code != 204:
1358
+ raise Exception(
1359
+ f"POST request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
1360
+ )
1361
+ if resp.status_code == 204:
1362
+ return resp
1363
+ else:
1364
+ return resp.json()
1365
+
1366
+ def put_app_key(self, email: str, app_name: str, key: str, body: dict) -> "dict":
1367
+ """
1368
+ Updates the allowed OAuth scopes associated with an app.
1369
+
1370
+ Note: Specify the complete list of scopes to apply. The specified list replaces
1371
+ the existing scopes on the app. Therefore, to add a scope, you must specify all
1372
+ of the existing scopes along with the added scope.
1373
+
1374
+ This API does not change the list of scopes in the API product(s) included in
1375
+ the app; rather, it sets allowed list of scopes in the scopes element under the
1376
+ apiProducts element in the attributes of the app.
1377
+
1378
+ Important: The specified scopes must already exist on the API product(s)
1379
+ associated with the app. You can't arbitrarily add a scope that does not already
1380
+ exist in an API product. For example, if the app has one API product with these
1381
+ scopes: READ, WRITE. You can't use this API to add a new scope, such as DELETE
1382
+ (unless the app has another product with that scope). If you do this, you'll get
1383
+ a 400 Bad Request error. For example:
1384
+
1385
+ {
1386
+ "code": "keymanagement.service.InvalidScopes",
1387
+ "message": "Invalid scopes. Scopes must be contained in [READ, WRITE]",
1388
+ "contexts": []
1389
+
1390
+ }
1391
+
1392
+ It would be allowed to remove one or both of the existing scopes, and later add
1393
+ one or both back.
1394
+ """
1395
+
1396
+ resource = f"/developers/{email}/apps/{app_name}/keys/{key}"
1397
+ url = f"{self.client.base_url}{resource}"
1398
+ resp = self.client.put(url=url, json=body)
1399
+ if resp.status_code != 200:
1400
+ raise Exception(
1401
+ f"PUT request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
1402
+ )
1403
+ return resp.json()
1404
+
1405
+ def delete_product_app_key_association(self, email: str, app_name: str, app_key: str, apiproduct_name: str) -> None:
1406
+ """
1407
+ Removes an API product from an app's consumer key, and thereby renders the app
1408
+ unable to access the API resources defined in that API product.
1409
+
1410
+ Note that the consumer key itself still exists after this call. Only the
1411
+ association of the key with the API product is removed.
1412
+ """
1413
+
1414
+ resource = f"/developers/{email}/apps/{app_name}/keys/{app_key}/apiproducts/{apiproduct_name}"
1415
+ url = f"{self.client.base_url}{resource}"
1416
+ resp = self.client.delete(url=url)
1417
+ if resp.status_code != 200:
1418
+ raise Exception(
1419
+ f"DELETE request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
1420
+ )
1421
+ return resp.json()
1422
+
1423
+ def post_product_app_key_association(self, email: str, app_name: str, key: str, apiproduct_name: str, **query_params) -> "dict":
1424
+ """
1425
+ Approves or revokes an API product for an API key. Call the API with the action
1426
+ query parameter set to approve or revoke (with no request body) and set the
1427
+ Content-type header to application/octet-stream. If successful, the HTTP status
1428
+ code for success is: 204 No Content
1429
+
1430
+ To consume API resources defined in an API product, an app's consumer key must
1431
+ be approved and it must also be approved for that specific API product.
1432
+
1433
+ Notes:
1434
+
1435
+ - The API product must already be associated with the app.
1436
+
1437
+ - Any access tokens associated with a revoked app key will remain active. However,
1438
+ Apigee Edge checks the status of the app key and if set to revoked it will not
1439
+ allow API calls to go through.
1440
+ """
1441
+
1442
+ resource = f"/developers/{email}/apps/{app_name}/keys/{key}/apiproducts/{apiproduct_name}"
1443
+ url = f"{self.client.base_url}{resource}"
1444
+ resp = self.client.post(url=url, params=query_params)
1445
+ if resp.status_code != 204:
1446
+ raise Exception(
1447
+ f"POST request to {resp.url} failed with status_code: {resp.status_code}, Reason: {resp.reason} and Content: {resp.text}"
1448
+ )
1449
+ return resp
1249
1450
 
1250
1451
 
1251
1452
  class UsersAPI:
@@ -5,6 +5,7 @@ This includes app setup/teardown, getting proxy info (proxy-under-test
5
5
  + identity-service of choice), getting products, registering them with
6
6
  the test app.
7
7
  """
8
+
8
9
  import functools
9
10
  import warnings
10
11
  from datetime import datetime
@@ -18,10 +19,11 @@ from .log import log, log_method
18
19
  from .apigee_apis import (
19
20
  ApigeeNonProdCredentials,
20
21
  ApigeeClient,
22
+ AppKeysAPI,
21
23
  DebugSessionsAPI,
22
24
  AccessTokensAPI,
23
25
  ApiProductsAPI,
24
- DeveloperAppsAPI
26
+ DeveloperAppsAPI,
25
27
  )
26
28
 
27
29
  APIGEE_BASE_URL = "https://api.enterprise.apigee.com/v1/"
@@ -47,6 +49,7 @@ def _apigee_app_base_url(nhsd_apim_config):
47
49
  url = APIGEE_BASE_URL + f"organizations/{org}/developers/{dev}/apps"
48
50
  return url
49
51
 
52
+
50
53
  @pytest.fixture(scope="session")
51
54
  @log_method
52
55
  def _apigee_app_base_url_no_dev(nhsd_apim_config):
@@ -54,14 +57,18 @@ def _apigee_app_base_url_no_dev(nhsd_apim_config):
54
57
  url = APIGEE_BASE_URL + f"organizations/{org}/apps"
55
58
  return url
56
59
 
60
+
57
61
  @functools.lru_cache(maxsize=None)
58
62
  @log_method
59
63
  def _get_proxy_json(session, nhsd_apim_proxy_url):
60
64
  """
61
65
  Query the apigee edge API to get data about the desired proxy, in particular its current deployment.
62
66
  """
63
- deployment_err_msg = "\n\tInvalid Access Token: Ensure APIGEE_ACCESS_TOKEN is valid."
64
- deployment_resp = session.get(nhsd_apim_proxy_url + "/deployments")
67
+ deployment_err_msg = (
68
+ "\n\tFailed to retrieve the proxy deployment data. "
69
+ + "Please check the validity of the APIGEE credentials and token as well as any headers."
70
+ )
71
+ deployment_resp = session.get(f"{nhsd_apim_proxy_url}/deployments")
65
72
  assert deployment_resp.status_code == 200, deployment_err_msg.format(deployment_resp.content)
66
73
  deployment_json = deployment_resp.json()
67
74
 
@@ -279,7 +286,9 @@ _TEST_APP = None
279
286
 
280
287
  @pytest.fixture(scope="session")
281
288
  @log_method
282
- def nhsd_apim_test_app(_create_test_app, _apigee_edge_session, _apigee_app_base_url, _apigee_app_base_url_no_dev, _test_app_id) -> Callable:
289
+ def nhsd_apim_test_app(
290
+ _create_test_app, _apigee_edge_session, _apigee_app_base_url, _apigee_app_base_url_no_dev, _test_app_id
291
+ ) -> Callable:
283
292
  """
284
293
  A Callable that gets you the current state of the test app.
285
294
  """
@@ -310,7 +319,7 @@ def nhsd_apim_test_app(_create_test_app, _apigee_edge_session, _apigee_app_base_
310
319
  return _TEST_APP
311
320
  if _test_app_id:
312
321
  resp = _apigee_edge_session.get(_apigee_app_base_url_no_dev + "/" + _test_app_id)
313
- else:
322
+ else:
314
323
  resp = _apigee_edge_session.get(_apigee_app_base_url + "/" + _create_test_app["name"])
315
324
  _TEST_APP = resp.json()
316
325
  return _TEST_APP
@@ -318,8 +327,8 @@ def nhsd_apim_test_app(_create_test_app, _apigee_edge_session, _apigee_app_base_
318
327
  return app
319
328
 
320
329
 
321
- @log_method
322
330
  @pytest.fixture(scope="session")
331
+ @log_method
323
332
  def nhsd_apim_unsubscribe_test_app_from_all_products(
324
333
  nhsd_apim_test_app, _apigee_edge_session, _apigee_app_base_url, _test_app_id
325
334
  ):
@@ -464,7 +473,7 @@ def _create_test_app(
464
473
  else:
465
474
  app = {
466
475
  "name": f"apim-auto-{uuid4()}",
467
- "callbackUrl": "https://example.org/callback",
476
+ "callbackUrl": "https://google.com/callback",
468
477
  "attributes": [{"name": "jwks-resource-url", "value": jwt_public_key_url}],
469
478
  }
470
479
  create_resp = _apigee_edge_session.post(_apigee_app_base_url, json=app)
@@ -508,7 +517,7 @@ def _create_function_scoped_test_app(
508
517
  else:
509
518
  app = {
510
519
  "name": f"apim-auto-{uuid4()}",
511
- "callbackUrl": "https://example.org/callback",
520
+ "callbackUrl": "https://google.com/callback",
512
521
  "attributes": [{"name": "jwks-resource-url", "value": jwt_public_key_url}],
513
522
  }
514
523
  create_resp = _apigee_edge_session.post(_apigee_app_base_url, json=app)
@@ -534,6 +543,7 @@ def _test_app_callback_url(_create_test_app):
534
543
  def _test_app_id(nhsd_apim_config):
535
544
  return nhsd_apim_config["APIGEE_APP_ID"]
536
545
 
546
+
537
547
  @pytest.fixture()
538
548
  @log_method
539
549
  def trace(_apigee_proxy):
@@ -541,15 +551,16 @@ def trace(_apigee_proxy):
541
551
  Authenticated wrapper around the DebugSessionsAPI class
542
552
  """
543
553
  config = ApigeeNonProdCredentials()
544
- client = ApigeeClient(config=config)
554
+ client = ApigeeClient(config=config)
545
555
  debug = DebugSessionsAPI(
546
556
  client=client,
547
557
  env_name=_apigee_proxy["environment"],
548
558
  api_name=_apigee_proxy["name"],
549
- revision_number=_apigee_proxy["revision"]
559
+ revision_number=_apigee_proxy["revision"],
550
560
  )
551
561
  return debug
552
562
 
563
+
553
564
  @pytest.fixture(scope="session")
554
565
  @log_method
555
566
  def access_token_api():
@@ -571,6 +582,7 @@ def products_api():
571
582
  client = ApigeeClient(config=config)
572
583
  return ApiProductsAPI(client=client)
573
584
 
585
+
574
586
  @pytest.fixture(scope="session")
575
587
  @log_method
576
588
  def developer_apps_api():
@@ -580,3 +592,14 @@ def developer_apps_api():
580
592
  config = ApigeeNonProdCredentials()
581
593
  client = ApigeeClient(config=config)
582
594
  return DeveloperAppsAPI(client=client)
595
+
596
+
597
+ @pytest.fixture(scope="session")
598
+ @log_method
599
+ def developer_app_keys_api():
600
+ """
601
+ Authenitcated wrapper for Apigee's developer app keys API
602
+ """
603
+ config = ApigeeNonProdCredentials()
604
+ client = ApigeeClient(config=config)
605
+ return AppKeysAPI(client=client)
@@ -1,23 +1,24 @@
1
1
  """
2
2
  This is a self-contained wrapper for a bunch of authentication
3
- methods in APIM. NOT ONLY the identity service is taken into
4
- account in here, you will also find authenticators for keycloak
5
- and more... Feel free to keep adding authenticators here and
3
+ methods in APIM. NOT ONLY the identity service is taken into
4
+ account in here, you will also find authenticators for keycloak
5
+ and more... Feel free to keep adding authenticators here and
6
6
  maybe move this file to its own library.
7
7
  """
8
8
 
9
9
  import uuid
10
- from time import time
11
- from typing import Literal
12
-
13
- import jwt
14
- from pydantic import BaseModel, HttpUrl, validator
15
10
  from abc import ABC, abstractmethod
11
+ from time import time
16
12
  from typing import Literal
17
13
  from urllib.parse import parse_qs, urlparse
18
14
 
15
+ import jwt
19
16
  import requests
20
17
  from lxml import html
18
+ from pydantic import BaseModel, HttpUrl, validator, AfterValidator
19
+ from typing_extensions import Annotated
20
+
21
+ HttpUrlString = Annotated[HttpUrl, AfterValidator(lambda v: str(v).rstrip("/"))]
21
22
 
22
23
 
23
24
  #### Config models
@@ -26,14 +27,22 @@ class KeycloakConfig(BaseModel):
26
27
 
27
28
  realm: Literal[
28
29
  "Cis2-mock-internal-dev",
30
+ "Cis2-mock-internal-dev-sandbox",
29
31
  "Cis2-mock-internal-qa",
32
+ "Cis2-mock-internal-qa-sandbox",
33
+ "Cis2-mock-ref",
34
+ "Cis2-mock-dev",
30
35
  "Cis2-mock-sandbox",
31
36
  "Cis2-mock-int",
32
37
  "NHS-Login-mock-internal-dev",
38
+ "NHS-Login-mock-internal-dev-sandbox",
33
39
  "NHS-Login-mock-internal-qa",
40
+ "NHS-Login-mock-internal-qa-sandbox",
41
+ "NHS-Login-mock-ref",
42
+ "NHS-Login-mock-dev",
34
43
  "NHS-Login-mock-sandbox",
35
44
  "NHS-Login-mock-int",
36
- "Api-producers", # just in case u want to get a cheeky token for proxygen ;)
45
+ "api-producers", # just in case u want to get a cheeky token for proxygen ;)
37
46
  ]
38
47
 
39
48
  @property
@@ -47,7 +56,8 @@ class KeycloakConfig(BaseModel):
47
56
  class KeycloakUserConfig(KeycloakConfig):
48
57
  client_id: str
49
58
  client_secret: str
50
- redirect_uri: HttpUrl = "https://example.org"
59
+ scope: str = "openid"
60
+ redirect_uri: HttpUrlString = "https://google.com"
51
61
  login_form: dict
52
62
 
53
63
 
@@ -57,7 +67,7 @@ class AuthorizationCodeConfig(BaseModel):
57
67
  def _identity_service_base_url(env):
58
68
  prefix = "https://"
59
69
  host = "api.service.nhs.uk"
60
- path = "/oauth2-mock" # lets just support mock auth v2...
70
+ path = "/oauth2-mock"
61
71
  if env != "prod":
62
72
  prefix += f"{env}."
63
73
  return f"{prefix}{host}{path}"
@@ -68,36 +78,23 @@ class AuthorizationCodeConfig(BaseModel):
68
78
  "internal-dev-sandbox",
69
79
  "internal-qa-sandbox",
70
80
  "ref",
81
+ "sandbox",
82
+ "dev",
71
83
  "int",
72
84
  "prod",
73
85
  ] = "internal-dev"
74
86
  org: Literal["nhsd-nonprod", "nhsd-prod"] = "nhsd-nonprod"
75
- callback_url: HttpUrl
76
- identity_service_base_url: HttpUrl = _identity_service_base_url(environment)
87
+ callback_url: HttpUrlString
88
+ identity_service_base_url: HttpUrlString = _identity_service_base_url(environment)
77
89
  client_id: str
78
90
  client_secret: str
79
91
  scope: Literal["nhs-login", "nhs-cis2"]
80
92
  login_form: dict
81
93
 
82
94
  @validator("environment")
83
- # The dream is to suport all the auth methods in all the environments
84
- # however this is only true for client_credentials at the time of writing
85
- # this library, we dont have at the moment a complete map between identity
86
- # service deployment environments and keycloak realms so authorization_code
87
- # and token_exchange are only supported in internal-dev, internal-qa and
88
- # int. We use validators to handle this and when the moment comes we can
89
- # just delete them.
90
95
  def validate_environment(cls, environment):
91
- if environment in [
92
- "internal-dev-sandbox",
93
- "internal-qa-sandbox",
94
- "sandbox",
95
- "ref",
96
- "prod",
97
- ]:
98
- raise ValueError(
99
- f"This is awkward.. we dont support auth_code flow for the {environment} just yet"
100
- )
96
+ if environment == "prod":
97
+ raise ValueError(f"We dont support testing in the production environment")
101
98
  return environment
102
99
 
103
100
 
@@ -118,6 +115,8 @@ class ClientCredentialsConfig(BaseModel):
118
115
  "internal-dev-sandbox",
119
116
  "internal-qa-sandbox",
120
117
  "ref",
118
+ "dev",
119
+ "sandbox",
121
120
  "int",
122
121
  "prod",
123
122
  ] = "internal-dev"
@@ -125,7 +124,7 @@ class ClientCredentialsConfig(BaseModel):
125
124
  client_id: str
126
125
  jwt_private_key: str
127
126
  jwt_kid: str
128
- identity_service_base_url: HttpUrl = _identity_service_base_url(environment)
127
+ identity_service_base_url: HttpUrlString = _identity_service_base_url(environment)
129
128
 
130
129
  def encode_jwt(self):
131
130
  url = f"{self.identity_service_base_url}/token"
@@ -137,9 +136,7 @@ class ClientCredentialsConfig(BaseModel):
137
136
  "exp": int(time()) + 300, # 5 minutes in the future
138
137
  }
139
138
  additional_headers = {"kid": self.jwt_kid}
140
- client_assertion = jwt.encode(
141
- claims, self.jwt_private_key, algorithm="RS512", headers=additional_headers
142
- )
139
+ client_assertion = jwt.encode(claims, self.jwt_private_key, algorithm="RS512", headers=additional_headers)
143
140
  return client_assertion
144
141
 
145
142
 
@@ -151,6 +148,7 @@ class TokenExchangeConfig(ClientCredentialsConfig):
151
148
  class KeycloakSignedJWTConfig:
152
149
  pass
153
150
 
151
+
154
152
  # currently only targets AOS environment
155
153
  class NHSLoginConfig(BaseModel):
156
154
  """Config needed to authenticate using NHS Login"""
@@ -160,14 +158,14 @@ class NHSLoginConfig(BaseModel):
160
158
  self.nhs_login_base_url = openid_config["issuer"]
161
159
 
162
160
  well_known_jwks: list = requests.get(openid_config["jwks_uri"]).json()
163
- well_known_key = well_known_jwks['keys'].pop()
161
+ well_known_key = well_known_jwks["keys"].pop()
164
162
  self.jwt_kid = well_known_key["kid"]
165
163
  self.alg = well_known_key["alg"]
166
164
 
167
165
  super().__init__(**kwargs)
168
166
 
169
- callback_url: HttpUrl = "https://nhsd-apim-testing-int-ns.herokuapp.com/nhslogin/callback"
170
- nhs_login_base_url: HttpUrl
167
+ callback_url: HttpUrlString = "https://nhsd-apim-testing-int-ns.herokuapp.com/nhslogin/callback"
168
+ nhs_login_base_url: HttpUrlString
171
169
  client_id: str = "APIM-1"
172
170
  jwt_private_key: str
173
171
  jwt_kid: str
@@ -185,16 +183,14 @@ class NHSLoginConfig(BaseModel):
185
183
  "exp": int(time()) + 300, # 5 minutes in the future
186
184
  }
187
185
  additional_headers = {"kid": self.jwt_kid}
188
- client_assertion = jwt.encode(
189
- claims, self.jwt_private_key, algorithm=self.alg, headers=additional_headers
190
- )
186
+ client_assertion = jwt.encode(claims, self.jwt_private_key, algorithm=self.alg, headers=additional_headers)
191
187
  return client_assertion
192
-
193
188
 
194
189
 
195
190
  class BananaAuthenticatorConfig: # Placeholder
196
191
  pass
197
192
 
193
+
198
194
  ### Authenticators
199
195
  class Authenticator(ABC):
200
196
  """Defines the interface"""
@@ -264,9 +260,7 @@ class AuthorizationCodeAuthenticator(Authenticator):
264
260
  },
265
261
  )
266
262
  if resp.status_code != 200:
267
- raise RuntimeError(
268
- f"{auth_url} request returned {resp.status_code}: {resp.text}"
269
- )
263
+ raise RuntimeError(f"{auth_url} request returned {resp.status_code}: {resp.text}")
270
264
  return resp
271
265
 
272
266
  @staticmethod
@@ -300,9 +294,7 @@ class AuthorizationCodeAuthenticator(Authenticator):
300
294
  form_submission_data,
301
295
  ):
302
296
  form_submit_url = authorize_form.action or authorize_response.request.url
303
- resp = session.request(
304
- authorize_form.method, form_submit_url, data=form_submission_data
305
- )
297
+ resp = session.request(authorize_form.method, form_submit_url, data=form_submission_data)
306
298
  # TODO: Investigate why when using the fixtures it returns 404 and when
307
299
  # using with external credentials returns 200
308
300
  # if resp.status_code != 200:
@@ -313,9 +305,7 @@ class AuthorizationCodeAuthenticator(Authenticator):
313
305
 
314
306
  @staticmethod
315
307
  def _get_auth_code_from_mock_auth(response_identity_service_login):
316
- qs = urlparse(
317
- response_identity_service_login.history[-1].headers["Location"]
318
- ).query
308
+ qs = urlparse(response_identity_service_login.history[-1].headers["Location"]).query
319
309
  auth_code = parse_qs(qs)["code"]
320
310
  if isinstance(auth_code, list):
321
311
  # in case there's multiple, this was a bug at one stage
@@ -337,16 +327,12 @@ class AuthorizationCodeAuthenticator(Authenticator):
337
327
  self.config.scope,
338
328
  )
339
329
 
340
- authorize_form = self._get_authorization_form(
341
- authorize_response.content.decode()
342
- )
330
+ authorize_form = self._get_authorization_form(authorize_response.content.decode())
343
331
  # 2. Parse the login page. For keycloak this presents an
344
332
  # HTML form, which must be filled in with valid data. The tester
345
333
  # can submits their login data with the `login_form` field.
346
334
 
347
- form_submission_data = self._get_authorize_form_submission_data(
348
- authorize_form, self.config.login_form
349
- )
335
+ form_submission_data = self._get_authorize_form_submission_data(authorize_form, self.config.login_form)
350
336
 
351
337
  # form_submission_data["username"] = 656005750104
352
338
  # # And here we inject a valid mock username for keycloak.
@@ -423,7 +409,7 @@ class KeycloakUserAuthenticator(Authenticator):
423
409
  params={
424
410
  "response_type": "code",
425
411
  "client_id": self.config.client_id,
426
- "scope": "openid",
412
+ "scope": self.config.scope,
427
413
  "redirect_uri": self.config.redirect_uri,
428
414
  },
429
415
  )
@@ -509,6 +495,7 @@ class NHSLoginSandpitAuthenticator(Authenticator):
509
495
 
510
496
  class NHSLoginAosAuthenticator(Authenticator):
511
497
  """Authenticates you against NHS-Login aos environment"""
498
+
512
499
  # This is only partially implemented. See below for usage:
513
500
  # https://nhsd-confluence.digital.nhs.uk/display/APM/KOP-085+Generating+NHS+login+ID+tokens
514
501
 
@@ -48,7 +48,7 @@ class HealthcareWorkerAuthorization(UserRestrictedAuthorization):
48
48
  """
49
49
 
50
50
  access: Literal["healthcare_worker"]
51
- level: Literal["aal1", "aal3"]
51
+ level: Literal["aal1", "aal2", "aal3"]
52
52
 
53
53
 
54
54
 
@@ -48,7 +48,8 @@ from .apigee_edge import (
48
48
  trace,
49
49
  products_api,
50
50
  access_token_api,
51
- developer_apps_api
51
+ developer_apps_api,
52
+ developer_app_keys_api
52
53
  )
53
54
  from .auth_journey import (
54
55
  _jwt_keys,
@@ -22,12 +22,23 @@ _SESSION = requests.session()
22
22
 
23
23
  @pytest.fixture()
24
24
  @log_method
25
- def _mock_jwks_api_key(_apigee_app_base_url, _apigee_edge_session, test_app, apigee_environment, _test_app_id):
25
+ def _mock_jwks_api_key(
26
+ _apigee_app_base_url,
27
+ _apigee_edge_session,
28
+ test_app,
29
+ apigee_environment,
30
+ _test_app_id,
31
+ ):
26
32
  # Apps in prod Apigee shouldn't rely on mock-jwks for their api key
27
- if apigee_environment in ["int", "prod"]: return ""
33
+ if apigee_environment in ["dev", "sandbox", "int", "prod"]:
34
+ return ""
28
35
 
29
36
  creds = get_app_credentials_for_product(
30
- _apigee_app_base_url, _apigee_edge_session, test_app(), f"mock-jwks-{apigee_environment}", _test_app_id
37
+ _apigee_app_base_url,
38
+ _apigee_edge_session,
39
+ test_app(),
40
+ f"mock-jwks-{apigee_environment}",
41
+ _test_app_id,
31
42
  )
32
43
  return creds["consumerKey"]
33
44
 
@@ -20,7 +20,14 @@ class _TokenCache:
20
20
  # only present on app-restricted tokens. we can inject
21
21
  # this ourselves. Assume 5 seconds ago, probably was more
22
22
  # recently
23
- token_data["issued_at"] = time() - 5000
23
+ token_data["issued_at"] = (int(time()) * 1000) - 5000
24
+
25
+ if not self.is_time_in_milliseconds(token_data["issued_at"]):
26
+ token_data["issued_at"] = int(token_data["issued_at"]) * 1000
27
+
28
+ if not self.is_expiry_in_milliseconds(token_data["expires_in"]):
29
+ token_data["expires_in"] = int(token_data["expires_in"]) * 1000
30
+
24
31
  self._cache[key] = token_data
25
32
 
26
33
  @log_method
@@ -33,18 +40,24 @@ class _TokenCache:
33
40
 
34
41
  old_token_data = self._cache[key]
35
42
  grace_period_seconds = 5
36
- now_ish = int(time()) + grace_period_seconds
43
+ now_ish = (int(time()) + grace_period_seconds) * 1000
37
44
 
38
45
  # issued_at is epoch_time in milliseconds
39
46
  # but expires_in is in seconds
40
47
  # => need factor of 1000 in this sum.
41
- expiry_time = int(old_token_data["issued_at"]) + 1000 * int(
42
- old_token_data["expires_in"]
43
- )
48
+
49
+ expiry_time = int(old_token_data["issued_at"]) + int(old_token_data["expires_in"])
44
50
  if now_ish > expiry_time:
45
51
  self._cache.pop(key)
46
52
  return None
47
53
  return old_token_data
54
+
55
+ def is_time_in_milliseconds(self, time_value):
56
+ return int(time_value) > 10**11 #Current time in secs not go over 11 digits
57
+
58
+ def is_expiry_in_milliseconds(self, expiry_value):
59
+ return int(expiry_value) > 86400 #Assuming seconds not go higher than 24hrs
60
+
48
61
 
49
62
 
50
63
  _CACHES = {}
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pytest-nhsd-apim
3
- Version: 3.3.6
3
+ Version: 5.0.15
4
4
  Summary: Pytest plugin accessing NHSDigital's APIM proxies
5
5
  Home-page: https://github.com/NHSDigital/pytest-nhsd-apim
6
6
  Author: Adrian Ciobanita
@@ -11,18 +11,33 @@ License: MIT
11
11
  Classifier: Framework :: Pytest
12
12
  Requires-Python: >=3.8
13
13
  Description-Content-Type: text/markdown
14
- Requires-Dist: Authlib (==0.15.5)
15
- Requires-Dist: cryptography (<42.0.0,===36.0.1)
16
- Requires-Dist: lxml (==4.9.1)
17
- Requires-Dist: pycryptodome (==3.13.0)
18
- Requires-Dist: PyJWT (==2.3.0)
19
- Requires-Dist: pyotp (==2.6.0)
20
- Requires-Dist: pytest (==6.2.5)
21
- Requires-Dist: requests (==2.27.1)
22
- Requires-Dist: toml (==0.10.2)
23
- Requires-Dist: typing-extensions (==4.3.0)
24
- Requires-Dist: pydantic (==1.9.1)
25
- Requires-Dist: wheel (<0.39.0,===0.37.1)
14
+ Requires-Dist: Authlib==1.6.1
15
+ Requires-Dist: cryptography==44.0.1
16
+ Requires-Dist: lxml==5.3.1
17
+ Requires-Dist: pycryptodome==3.20.0
18
+ Requires-Dist: PyJWT==2.8.0
19
+ Requires-Dist: pyotp==2.9.0
20
+ Requires-Dist: pytest==8.2.0
21
+ Requires-Dist: requests==2.32.0
22
+ Requires-Dist: toml==0.10.2
23
+ Requires-Dist: typing-extensions==4.12.2
24
+ Requires-Dist: pydantic==2.9.2
25
+ Requires-Dist: wheel<0.45.0,===0.37.1
26
+ Requires-Dist: pydantic-settings==2.2.1
27
+ Requires-Dist: setuptools==80.0.1
28
+ Requires-Dist: urllib3==2.6.1
29
+ Dynamic: author
30
+ Dynamic: author-email
31
+ Dynamic: classifier
32
+ Dynamic: description
33
+ Dynamic: description-content-type
34
+ Dynamic: home-page
35
+ Dynamic: license
36
+ Dynamic: maintainer
37
+ Dynamic: maintainer-email
38
+ Dynamic: requires-dist
39
+ Dynamic: requires-python
40
+ Dynamic: summary
26
41
 
27
42
  # pytest-nhsd-apim
28
43
 
@@ -99,6 +114,7 @@ The APIs we offer at the moment are:
99
114
  | ApiProductsAPI | [here](/src/pytest_nhsd_apim/apigee_apis.py#L575) |[Overview](https://apidocs.apigee.com/docs/api-products/1/overview)|
100
115
  | DebugSessionsAPI | [here](/src/pytest_nhsd_apim/apigee_apis.py#L844) |[Overview](https://apidocs.apigee.com/docs/debug-sessions/1/overview)|
101
116
  | AccessTokensAPI | [here](/src/pytest_nhsd_apim/apigee_apis.py#L983) |[Overview](https://apidocs.apigee.com/docs/oauth-20-access-tokens/1/overview)|
117
+ | AppKeysAPI | [here](/src/pytest_nhsd_apim/apigee_apis.py#L1243) |[Overview](https://apidocs.apigee.com/docs/developer-app-keys/1/overview)|
102
118
 
103
119
  For a more detailed implementation of the available APIs please refer to the tests [here](/tests/test_apigee_apis.py).
104
120
  We will keep adding APIs with time, if you are looking for a particular APIs not listed above please feel free to open a pull request and send it to us.
@@ -0,0 +1,16 @@
1
+ pytest_nhsd_apim/__init__.py,sha256=CRwQfUDrbXIqvJX6OfTR4jGdhlU9kjGmBAptJasRg7E,72
2
+ pytest_nhsd_apim/apigee_apis.py,sha256=u_sHjISj4XKgidVje1Uzq4LblMGnX4lrrrMWZM-SEF4,67289
3
+ pytest_nhsd_apim/apigee_edge.py,sha256=9Vtad7LGyNAnRoSj9I-1T6mylgRlDF88ISanRAjpU5c,19076
4
+ pytest_nhsd_apim/auth_journey.py,sha256=UovbLXhokUnikrMOaXIhjV8t5aRrcxinAbS96nfZWcY,5154
5
+ pytest_nhsd_apim/config.py,sha256=ScKfV8iURqDXX2ajgGsRDcVn9RZy2DxLoEU2QQt9lmA,4246
6
+ pytest_nhsd_apim/identity_service.py,sha256=1SYR8yY66XTygF6jr3dUD22lAMnz6IImMaDQtm5YwXg,19749
7
+ pytest_nhsd_apim/log.py,sha256=8gYHqzlQM546FB2XvFmLTk1YfZRNeNhIwLmOy0GScr8,2685
8
+ pytest_nhsd_apim/nhsd_apim_authorization.py,sha256=GR8GfbIZyqBC4jsSZMYNifDH52E3VWoIa7lrpuvIbaM,3513
9
+ pytest_nhsd_apim/pytest_nhsd_apim.py,sha256=ZCItUqcM23CCmcRyGU8bEwI3vJnNcGdoOlbSfvYILR8,5242
10
+ pytest_nhsd_apim/secrets.py,sha256=yIYwOZwpliIomtqSJGIYRbAE2HYvLvQU4W2kOa9TnXo,2354
11
+ pytest_nhsd_apim/token_cache.py,sha256=u22VEoHxGkpOzPjsCOHFSW-5aM5zlj2uejwV5mB5Lbo,4140
12
+ pytest_nhsd_apim-5.0.15.dist-info/METADATA,sha256=VnNWHfWkNLapZA3g4RZWmxocZ82v3buj4MRY0nYaB_4,4990
13
+ pytest_nhsd_apim-5.0.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ pytest_nhsd_apim-5.0.15.dist-info/entry_points.txt,sha256=XWicT3meTpqLXnZcXNoAd2IfXspFPlNgMgLBMy4nqwQ,57
15
+ pytest_nhsd_apim-5.0.15.dist-info/top_level.txt,sha256=ZK5GZP-g_K8gNfm4a58T9JCRb0i1X267ngvNelCGgfQ,17
16
+ pytest_nhsd_apim-5.0.15.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.38.4)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,16 +0,0 @@
1
- pytest_nhsd_apim/__init__.py,sha256=CRwQfUDrbXIqvJX6OfTR4jGdhlU9kjGmBAptJasRg7E,72
2
- pytest_nhsd_apim/apigee_apis.py,sha256=b1Ay1MBCdlcy3-QoQhfeFwnGkY2bmYAXJhEzCO1ZK54,57430
3
- pytest_nhsd_apim/apigee_edge.py,sha256=a7hXBgm4hmUyyzrxJkWapsbWVBVGQAB1r9Mdnyw9-GU,18675
4
- pytest_nhsd_apim/auth_journey.py,sha256=UovbLXhokUnikrMOaXIhjV8t5aRrcxinAbS96nfZWcY,5154
5
- pytest_nhsd_apim/config.py,sha256=ScKfV8iURqDXX2ajgGsRDcVn9RZy2DxLoEU2QQt9lmA,4246
6
- pytest_nhsd_apim/identity_service.py,sha256=tFpu20oiOVj2Nb78mGKfwDchbc-mUX6x0DLgUozy190,20102
7
- pytest_nhsd_apim/log.py,sha256=8gYHqzlQM546FB2XvFmLTk1YfZRNeNhIwLmOy0GScr8,2685
8
- pytest_nhsd_apim/nhsd_apim_authorization.py,sha256=TE_6YnoM7kpRReiZP6-Z52rRs9bUuax1mpXqkbzg8zQ,3505
9
- pytest_nhsd_apim/pytest_nhsd_apim.py,sha256=_snJGTUVsHA70FGrKNNXjx9yNc_UjO1Ffhw0SLQtYUs,5214
10
- pytest_nhsd_apim/secrets.py,sha256=ZoTMRQGElAq4NNOx6FelINKo_YVNBWdgDY8x3bs_Zr0,2272
11
- pytest_nhsd_apim/token_cache.py,sha256=6L08taTlSyRsx2NCb0LSGsHdWx_wmqwlFtcF7pZMhUg,3540
12
- pytest_nhsd_apim-3.3.6.dist-info/METADATA,sha256=mKf7s7BQ4TuHv-imklQwzzYNuA7TqCdJ4Lu2QzoZmgw,4526
13
- pytest_nhsd_apim-3.3.6.dist-info/WHEEL,sha256=2wepM1nk4DS4eFpYrW1TTqPcoGNfHhhO_i5m4cOimbo,92
14
- pytest_nhsd_apim-3.3.6.dist-info/entry_points.txt,sha256=XWicT3meTpqLXnZcXNoAd2IfXspFPlNgMgLBMy4nqwQ,57
15
- pytest_nhsd_apim-3.3.6.dist-info/top_level.txt,sha256=ZK5GZP-g_K8gNfm4a58T9JCRb0i1X267ngvNelCGgfQ,17
16
- pytest_nhsd_apim-3.3.6.dist-info/RECORD,,