rp_python_sdk 1.1.7__tar.gz → 1.3.0__tar.gz

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.
Files changed (22) hide show
  1. rp_python_sdk-1.1.7/README.md → rp_python_sdk-1.3.0/PKG-INFO +59 -1
  2. rp_python_sdk-1.1.7/PKG-INFO → rp_python_sdk-1.3.0/README.md +37 -23
  3. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/pyproject.toml +4 -4
  4. rp_python_sdk-1.3.0/rp_python_sdk/cache/__init__.py +0 -0
  5. rp_python_sdk-1.3.0/rp_python_sdk/cache/http_cache.py +117 -0
  6. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/get_participants.py +59 -16
  7. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/pushed_authorisation_request.py +5 -3
  8. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/retrieve_tokens.py +23 -8
  9. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/user_info.py +4 -3
  10. rp_python_sdk-1.3.0/rp_python_sdk/endpoints/util/version_info.py +2 -0
  11. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/relying_party_client_sdk.py +6 -4
  12. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/sdk_config.py +4 -0
  13. rp_python_sdk-1.1.7/rp_python_sdk/endpoints/util/version_info.py +0 -2
  14. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/__init__.py +0 -0
  15. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/util/fapi.py +0 -0
  16. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/util/mapping.py +0 -0
  17. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/util/system_info.py +0 -0
  18. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/endpoints/util/user_agent.py +0 -0
  19. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/filters/participant_filters.py +0 -0
  20. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/model.py +0 -0
  21. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/relying_party_client_sdk_exception.py +0 -0
  22. {rp_python_sdk-1.1.7 → rp_python_sdk-1.3.0}/rp_python_sdk/setup_logger.py +0 -0
@@ -1,3 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: rp_python_sdk
3
+ Version: 1.3.0
4
+ Summary: Python SDK for Relying Parties to enable simple integration with the Digital Identity ecosystem
5
+ License: Proprietary
6
+ Author: Erik Pragt
7
+ Author-email: erik.pragt@connectid.com.au
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: authlib (>=1.6.6,<2.0.0)
15
+ Requires-Dist: cffi (>=2.0.0,<3.0.0)
16
+ Requires-Dist: cryptography (>=46.0.5,<47.0.0)
17
+ Requires-Dist: joserfc (>=1.6.3,<2.0.0)
18
+ Requires-Dist: requests (>=2.32.5,<3.0.0)
19
+ Requires-Dist: urllib3 (>=2.6.3,<3.0.0)
20
+ Description-Content-Type: text/markdown
21
+
1
22
  # Python SDK for Relying Parties
2
23
 
3
24
  [![Build Python SDK](https://github.com/connectid-tools/rp-python-sdk/actions/workflows/build.yml/badge.svg)](https://github.com/connectid-tools/rp-python-sdk/actions/workflows/build.yml)
@@ -182,7 +203,12 @@ client = RelyingPartyClientSdk(
182
203
  client_id='https://rp.directory.sandbox.connectid.com.au/openid_relying_party/280518db-9807-4824-b080-324d94b45f6a',
183
204
  signing_kid='1X8udt28NY8NiSS8OJFvabv63K5igyFx5pM5ajKlMx8',
184
205
  custom_config=CustomConfig(
185
- enable_auto_compliance_verification=False
206
+ enable_auto_compliance_verification=False,
207
+ # HTTP cache configuration (optional)
208
+ cache_enabled=True, # Enable/disable caching (default: True)
209
+ cache_ttl_minutes=10, # Cache TTL in minutes (default: 10)
210
+ cache_max_entries=100, # Max cached entries (default: 100)
211
+ cache_max_element_size_bytes=5242880 # Max size per entry in bytes (default: 5MB)
186
212
  )
187
213
  ),
188
214
  )
@@ -190,6 +216,19 @@ client = RelyingPartyClientSdk(
190
216
  participants = client.get_participants()
191
217
  ```
192
218
 
219
+ ### HTTP Response Caching
220
+
221
+ The SDK includes an in-memory LRU cache that automatically caches HTTP responses for stable endpoints (participants registry,
222
+ OIDC discovery documents, and JWKS endpoints) to improve performance and reduce network calls. The cache is enabled by default
223
+ with sensible defaults, but can be customized or disabled via the `CustomConfig` options shown above.
224
+
225
+ Key cache features:
226
+ - **Thread-safe**: Safe for concurrent use across multiple threads
227
+ - **LRU eviction**: Automatically evicts least recently used entries when the cache is full
228
+ - **TTL expiration**: Entries expire after the configured TTL (default: 10 minutes)
229
+ - **Size limits**: Prevents caching of responses that exceed the configured size limit
230
+ - **Transparent**: Caching is automatic and transparent to SDK users
231
+
193
232
  The response will contain List of Organisations and their Authorisation Server, with an object structure similar to below.
194
233
 
195
234
  They key fields of interest are:
@@ -412,6 +451,24 @@ poetry publish
412
451
 
413
452
  ## Release Notes
414
453
 
454
+ ## 1.3.0 (23 Mar, 2026)
455
+ - **OpenID Connect Discovery Section 4.3 Compliance**: Implemented issuer validation to ensure the issuer value in the provider configuration matches the Issuer URL used to retrieve it
456
+ - Added validation that compares the issuer in `/.well-known/openid-configuration` response with the expected issuer URL
457
+ - Properly handles trailing slash normalisation per RFC 3986
458
+ - Throws descriptive exception when issuer mismatch is detected, stopping the flow as required by the specification
459
+ - Validates both fresh and cached issuer metadata
460
+
461
+ ### 1.2.0 (Feb 11, 2026)
462
+ - **New Feature: HTTP Response Cache** - Added in-memory LRU cache for HTTP responses to improve SDK performance
463
+ - Caches participants registry, OIDC discovery documents, and JWKS endpoints
464
+ - Configurable TTL (default: 10 minutes), max entries (default: 100), and size limits (default: 5MB per entry)
465
+ - Thread-safe with automatic LRU eviction
466
+ - Cache can be disabled or customized via `CustomConfig`:
467
+ - `cache_enabled` (bool, default: `True`)
468
+ - `cache_ttl_minutes` (int, default: `10`)
469
+ - `cache_max_entries` (int, default: `100`)
470
+ - `cache_max_element_size_bytes` (int, default: `5242880`)
471
+
415
472
  ### 1.1.7 (Jan 9, 2026)
416
473
  - Security fix: Updated urllib3 to 2.6.3 to address high severity vulnerability (CVE-2026-21441)
417
474
 
@@ -486,3 +543,4 @@ def send_pushed_authorisation_request(config: SdkConfig,
486
543
 
487
544
  ### 0.1.0 (March 27, 2024)
488
545
  - Initial version
546
+
@@ -1,24 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: rp_python_sdk
3
- Version: 1.1.7
4
- Summary: Python SDK for Relying Parties to enable simple integration with the Digital Identity ecosystem
5
- License: Proprietary
6
- Author: Erik Pragt
7
- Author-email: erik.pragt@connectid.com.au
8
- Requires-Python: >=3.12,<4.0
9
- Classifier: License :: Other/Proprietary License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.12
12
- Classifier: Programming Language :: Python :: 3.13
13
- Classifier: Programming Language :: Python :: 3.14
14
- Requires-Dist: authlib (>=1.6.6,<2.0.0)
15
- Requires-Dist: cffi (>=2.0.0,<3.0.0)
16
- Requires-Dist: cryptography (>=46.0.2,<47.0.0)
17
- Requires-Dist: joserfc (>=1.6.1,<2.0.0)
18
- Requires-Dist: requests (>=2.32.5,<3.0.0)
19
- Requires-Dist: urllib3 (>=2.6.3,<3.0.0)
20
- Description-Content-Type: text/markdown
21
-
22
1
  # Python SDK for Relying Parties
23
2
 
24
3
  [![Build Python SDK](https://github.com/connectid-tools/rp-python-sdk/actions/workflows/build.yml/badge.svg)](https://github.com/connectid-tools/rp-python-sdk/actions/workflows/build.yml)
@@ -203,7 +182,12 @@ client = RelyingPartyClientSdk(
203
182
  client_id='https://rp.directory.sandbox.connectid.com.au/openid_relying_party/280518db-9807-4824-b080-324d94b45f6a',
204
183
  signing_kid='1X8udt28NY8NiSS8OJFvabv63K5igyFx5pM5ajKlMx8',
205
184
  custom_config=CustomConfig(
206
- enable_auto_compliance_verification=False
185
+ enable_auto_compliance_verification=False,
186
+ # HTTP cache configuration (optional)
187
+ cache_enabled=True, # Enable/disable caching (default: True)
188
+ cache_ttl_minutes=10, # Cache TTL in minutes (default: 10)
189
+ cache_max_entries=100, # Max cached entries (default: 100)
190
+ cache_max_element_size_bytes=5242880 # Max size per entry in bytes (default: 5MB)
207
191
  )
208
192
  ),
209
193
  )
@@ -211,6 +195,19 @@ client = RelyingPartyClientSdk(
211
195
  participants = client.get_participants()
212
196
  ```
213
197
 
198
+ ### HTTP Response Caching
199
+
200
+ The SDK includes an in-memory LRU cache that automatically caches HTTP responses for stable endpoints (participants registry,
201
+ OIDC discovery documents, and JWKS endpoints) to improve performance and reduce network calls. The cache is enabled by default
202
+ with sensible defaults, but can be customized or disabled via the `CustomConfig` options shown above.
203
+
204
+ Key cache features:
205
+ - **Thread-safe**: Safe for concurrent use across multiple threads
206
+ - **LRU eviction**: Automatically evicts least recently used entries when the cache is full
207
+ - **TTL expiration**: Entries expire after the configured TTL (default: 10 minutes)
208
+ - **Size limits**: Prevents caching of responses that exceed the configured size limit
209
+ - **Transparent**: Caching is automatic and transparent to SDK users
210
+
214
211
  The response will contain List of Organisations and their Authorisation Server, with an object structure similar to below.
215
212
 
216
213
  They key fields of interest are:
@@ -433,6 +430,24 @@ poetry publish
433
430
 
434
431
  ## Release Notes
435
432
 
433
+ ## 1.3.0 (23 Mar, 2026)
434
+ - **OpenID Connect Discovery Section 4.3 Compliance**: Implemented issuer validation to ensure the issuer value in the provider configuration matches the Issuer URL used to retrieve it
435
+ - Added validation that compares the issuer in `/.well-known/openid-configuration` response with the expected issuer URL
436
+ - Properly handles trailing slash normalisation per RFC 3986
437
+ - Throws descriptive exception when issuer mismatch is detected, stopping the flow as required by the specification
438
+ - Validates both fresh and cached issuer metadata
439
+
440
+ ### 1.2.0 (Feb 11, 2026)
441
+ - **New Feature: HTTP Response Cache** - Added in-memory LRU cache for HTTP responses to improve SDK performance
442
+ - Caches participants registry, OIDC discovery documents, and JWKS endpoints
443
+ - Configurable TTL (default: 10 minutes), max entries (default: 100), and size limits (default: 5MB per entry)
444
+ - Thread-safe with automatic LRU eviction
445
+ - Cache can be disabled or customized via `CustomConfig`:
446
+ - `cache_enabled` (bool, default: `True`)
447
+ - `cache_ttl_minutes` (int, default: `10`)
448
+ - `cache_max_entries` (int, default: `100`)
449
+ - `cache_max_element_size_bytes` (int, default: `5242880`)
450
+
436
451
  ### 1.1.7 (Jan 9, 2026)
437
452
  - Security fix: Updated urllib3 to 2.6.3 to address high severity vulnerability (CVE-2026-21441)
438
453
 
@@ -507,4 +522,3 @@ def send_pushed_authorisation_request(config: SdkConfig,
507
522
 
508
523
  ### 0.1.0 (March 27, 2024)
509
524
  - Initial version
510
-
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rp_python_sdk"
3
- version = "1.1.7"
3
+ version = "1.3.0"
4
4
  description = "Python SDK for Relying Parties to enable simple integration with the Digital Identity ecosystem"
5
5
  authors = ["Erik Pragt <erik.pragt@connectid.com.au>"]
6
6
  license = "Proprietary"
@@ -10,8 +10,8 @@ readme = "README.md"
10
10
  python = "^3.12"
11
11
  requests = "^2.32.5"
12
12
  authlib = "^1.6.6"
13
- cryptography = "^46.0.2"
14
- joserfc = "^1.6.1"
13
+ cryptography = "^46.0.5"
14
+ joserfc = "^1.6.3"
15
15
  cffi = "^2.0.0"
16
16
  urllib3 = "^2.6.3"
17
17
 
@@ -21,7 +21,7 @@ httpx = "^0.28.1"
21
21
  toml = "^0.10.2"
22
22
 
23
23
  [tool.poetry.group.dev.dependencies]
24
- black = "^25.0.0"
24
+ black = "^26.0.0"
25
25
  pre-commit = "^4.5.1"
26
26
  mypy = "^1.19.1"
27
27
  requests-mock = "^1.12.1"
File without changes
@@ -0,0 +1,117 @@
1
+ from collections import OrderedDict
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, timedelta, timezone
4
+ from threading import Lock
5
+ from typing import Optional
6
+ from rp_python_sdk.sdk_config import CustomConfig
7
+
8
+ from rp_python_sdk.setup_logger import logger
9
+
10
+ @dataclass
11
+ class CacheEntry:
12
+ content: str
13
+ fetched_at: datetime
14
+ size_bytes: int
15
+
16
+
17
+ class HttpCache:
18
+ def __init__(self, config: CustomConfig) -> None:
19
+ self._enabled = config.cache_enabled
20
+ self._ttl_minutes = config.cache_ttl_minutes
21
+ self._max_entries = config.cache_max_entries
22
+ self._max_element_size_bytes = config.cache_max_element_size_bytes
23
+
24
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
25
+ self._lock = Lock()
26
+
27
+ logger.debug(
28
+ f"HTTP cache initialized: enabled={self._enabled}, ttlMinutes={self._ttl_minutes}, "
29
+ f"maxEntries={self._max_entries}, maxElementSize={self._max_element_size_bytes}"
30
+ )
31
+
32
+ def get(self, url: str) -> Optional[str]:
33
+ if not self._enabled:
34
+ return None
35
+
36
+ with self._lock:
37
+ if url not in self._cache:
38
+ return None
39
+
40
+ entry = self._cache[url]
41
+
42
+ if self._is_stale_entry(entry):
43
+ logger.debug(f"Cache entry stale for URL: {url}")
44
+ return None
45
+
46
+ self._cache.move_to_end(url)
47
+ logger.debug(f"Cache hit for URL: {url}")
48
+ return entry.content
49
+
50
+ def put(self, url: str, content: str) -> None:
51
+ if not self._enabled:
52
+ return
53
+
54
+ size_bytes = len(content.encode('utf-8'))
55
+
56
+ if size_bytes > self._max_element_size_bytes:
57
+ logger.warning(
58
+ f"Response too large to cache: {size_bytes} bytes (max: {self._max_element_size_bytes}), URL: {url}"
59
+ )
60
+ return
61
+
62
+ with self._lock:
63
+ entry = CacheEntry(
64
+ content=content,
65
+ fetched_at=datetime.now(timezone.utc),
66
+ size_bytes=size_bytes
67
+ )
68
+
69
+ self._cache[url] = entry
70
+ self._cache.move_to_end(url)
71
+
72
+ logger.debug(f"Cached response for URL: {url}, size: {size_bytes} bytes")
73
+
74
+ if len(self._cache) > self._max_entries:
75
+ evicted_url, _ = self._cache.popitem(last=False)
76
+ logger.debug(f"Evicted cache entry for URL: {evicted_url}")
77
+
78
+ def is_stale(self, url: str) -> bool:
79
+ if not self._enabled:
80
+ return False
81
+
82
+ with self._lock:
83
+ if url not in self._cache:
84
+ return False
85
+
86
+ entry = self._cache[url]
87
+ return self._is_stale_entry(entry)
88
+
89
+ def evict(self, url: str) -> None:
90
+ if not self._enabled:
91
+ return
92
+
93
+ with self._lock:
94
+ if url in self._cache:
95
+ del self._cache[url]
96
+ logger.debug(f"Evicted cache entry for URL: {url}")
97
+
98
+ def clear(self) -> None:
99
+ if not self._enabled:
100
+ return
101
+
102
+ with self._lock:
103
+ self._cache.clear()
104
+ logger.debug("Cache cleared")
105
+
106
+ def size(self) -> int:
107
+ with self._lock:
108
+ return len(self._cache)
109
+
110
+ def is_enabled(self) -> bool:
111
+ return self._enabled
112
+
113
+ def _is_stale_entry(self, entry: CacheEntry) -> bool:
114
+ expiry_time: datetime = entry.fetched_at + timedelta(minutes=self._ttl_minutes)
115
+ current_time: datetime = datetime.now(timezone.utc)
116
+ is_stale: bool = current_time >= expiry_time
117
+ return is_stale
@@ -14,10 +14,11 @@ from rp_python_sdk.model import Participant
14
14
  from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientSdkException
15
15
  from rp_python_sdk.sdk_config import SdkConfig
16
16
  from rp_python_sdk.setup_logger import logger
17
+ from rp_python_sdk.cache.http_cache import HttpCache
17
18
 
18
19
 
19
- def get_participants(config: SdkConfig) -> list[Participant]:
20
- participants = _retrieve_full_participants_list(config)
20
+ def get_participants(config: SdkConfig, cache: HttpCache) -> list[Participant]:
21
+ participants = _retrieve_full_participants_list(config, cache)
21
22
 
22
23
  filtered_participants = remove_fallback_identity_service_provider(participants)
23
24
 
@@ -47,8 +48,8 @@ def get_participants(config: SdkConfig) -> list[Participant]:
47
48
  return filtered_participants
48
49
 
49
50
 
50
- def retrieve_fallback_provider_participants(config: SdkConfig) -> list[Participant]:
51
- participants = _retrieve_full_participants_list(config)
51
+ def retrieve_fallback_provider_participants(config: SdkConfig, cache: HttpCache) -> list[Participant]:
52
+ participants = _retrieve_full_participants_list(config, cache)
52
53
 
53
54
  filtered_participants = remove_out_of_date_certifications(participants, date.today())
54
55
  filtered_participants = remove_unofficial_certifications(filtered_participants)
@@ -59,10 +60,21 @@ def retrieve_fallback_provider_participants(config: SdkConfig) -> list[Participa
59
60
  return filtered_participants
60
61
 
61
62
 
62
- def _retrieve_full_participants_list(config: SdkConfig) -> list[Participant]:
63
- logger.info(f"Retrieving all identity providers from {config.registry_participants_uri}")
63
+ def _retrieve_full_participants_list(config: SdkConfig, cache: HttpCache) -> list[Participant]:
64
+ url = config.registry_participants_uri
64
65
 
65
- response = requests.get(config.registry_participants_uri,
66
+ # Try cache first
67
+ cached = cache.get(url)
68
+ if cached:
69
+ logger.debug(f"Cache hit for URL: {url}")
70
+ participant_list: list[Participant] = from_dict(Participant, json.loads(cached), pascal_case=True)
71
+ logger.info(f"Retrieved identity providers from cache, num orgs found: {len(participant_list)}")
72
+ return participant_list
73
+
74
+ logger.debug(f"Cache miss for URL: {url}")
75
+ logger.info(f"Retrieving all identity providers from {url}")
76
+
77
+ response = requests.get(url,
66
78
  timeout=config.custom_config.timeout_in_seconds,
67
79
  headers={
68
80
  'User-Agent': build_user_agent(config.client_id)
@@ -71,6 +83,10 @@ def _retrieve_full_participants_list(config: SdkConfig) -> list[Participant]:
71
83
  raise RelyingPartyClientSdkException(
72
84
  f"Call to identity provider endpoint failed with code: {response.status_code}")
73
85
 
86
+ # Cache the successful response
87
+ cache.put(url, response.text)
88
+ logger.debug(f"Cached response for URL: {url}, size: {len(response.text)} bytes")
89
+
74
90
  # participant_list: list[Participant] = Participant.from_json(response.text)
75
91
  participant_list: list[Participant] = from_dict(Participant, json.loads(response.text), pascal_case=True)
76
92
 
@@ -78,7 +94,7 @@ def _retrieve_full_participants_list(config: SdkConfig) -> list[Participant]:
78
94
  return participant_list
79
95
 
80
96
 
81
- def get_auth_server_details(config: SdkConfig, authorisation_server_id: str) -> AuthorisationServer:
97
+ def get_auth_server_details(config: SdkConfig, authorisation_server_id: str, cache: HttpCache) -> AuthorisationServer:
82
98
  def find_auth_server_by_id(participants: list[Participant], target_id: str) -> AuthorisationServer | None:
83
99
  for participant in participants:
84
100
  for server in participant.authorisation_servers:
@@ -87,11 +103,11 @@ def get_auth_server_details(config: SdkConfig, authorisation_server_id: str) ->
87
103
  return None
88
104
 
89
105
  # Search in main participants
90
- result = find_auth_server_by_id(get_participants(config), authorisation_server_id)
106
+ result = find_auth_server_by_id(get_participants(config, cache), authorisation_server_id)
91
107
 
92
108
  # If not found, check fallback provider participants
93
109
  if result is None:
94
- fallback_participants = retrieve_fallback_provider_participants(config)
110
+ fallback_participants = retrieve_fallback_provider_participants(config, cache)
95
111
  result = find_auth_server_by_id(fallback_participants, authorisation_server_id)
96
112
 
97
113
  if result is None:
@@ -101,19 +117,46 @@ def get_auth_server_details(config: SdkConfig, authorisation_server_id: str) ->
101
117
  return result
102
118
 
103
119
 
104
- def get_issuer_metadata(authorisation_server: AuthorisationServer) -> IssuerMetadata:
105
- auth_server_issuer = authorisation_server.open_i_d_discovery_document
120
+ def _validate_issuer_match(discovery_url: str, actual_issuer: str) -> None:
121
+ well_known_suffix = "/.well-known/openid-configuration"
122
+ if well_known_suffix not in discovery_url or not actual_issuer:
123
+ return
124
+ expected_issuer = discovery_url[:discovery_url.index(well_known_suffix)]
125
+ if expected_issuer.rstrip("/") != actual_issuer.rstrip("/"):
126
+ raise RelyingPartyClientSdkException(
127
+ f"Issuer mismatch: discovery URL implies issuer '{expected_issuer}' "
128
+ f"but discovery document contains issuer '{actual_issuer}'")
129
+
130
+
131
+ def get_issuer_metadata(authorisation_server: AuthorisationServer, cache: HttpCache) -> IssuerMetadata:
132
+ url = authorisation_server.open_i_d_discovery_document
133
+
134
+ # Try cache first
135
+ cached = cache.get(url)
136
+ if cached:
137
+ logger.debug(f"Cache hit for URL: {url}")
138
+ metadata = from_dict(IssuerMetadata, json.loads(cached))
139
+ _validate_issuer_match(url, metadata.issuer)
140
+ return metadata
141
+
142
+ logger.debug(f"Cache miss for URL: {url}")
106
143
 
107
144
  try:
108
- logger.info(f"Getting discovery document from {auth_server_issuer}")
109
- response = requests.get(auth_server_issuer)
145
+ logger.info(f"Getting discovery document from {url}")
146
+ response = requests.get(url)
110
147
  response.raise_for_status() # Raises an exception for 4XX or 5XX errors
111
148
 
112
- return from_dict(IssuerMetadata, json.loads(response.text))
149
+ # Cache the successful response
150
+ cache.put(url, response.text)
151
+ logger.debug(f"Cached response for URL: {url}, size: {len(response.text)} bytes")
152
+
153
+ metadata = from_dict(IssuerMetadata, json.loads(response.text))
154
+ _validate_issuer_match(url, metadata.issuer)
155
+ return metadata
113
156
 
114
157
  except requests.exceptions.HTTPError as http_err:
115
158
  raise RelyingPartyClientSdkException(
116
159
  f"Call to identity provider endpoint failed with code: {response.status_code}") from http_err
117
160
  except requests.exceptions.RequestException as req_err:
118
161
  raise RelyingPartyClientSdkException(
119
- f"Failed to connect to authorisation service {auth_server_issuer}") from req_err
162
+ f"Failed to connect to authorisation service {url}") from req_err
@@ -16,6 +16,7 @@ from rp_python_sdk.model import PARResponse
16
16
  from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientSdkException
17
17
  from rp_python_sdk.sdk_config import SdkConfig
18
18
  from rp_python_sdk.setup_logger import logger
19
+ from rp_python_sdk.cache.http_cache import HttpCache
19
20
 
20
21
  extended_claims = {"over16", "over18", "over21", "over25", "over65", "beneficiary_account_au",
21
22
  "beneficiary_account_au_payid", "beneficiary_account_international"}
@@ -25,14 +26,15 @@ def send_pushed_authorisation_request(config: SdkConfig,
25
26
  authorisation_server_id: str,
26
27
  essential_claims: set[str],
27
28
  voluntary_claims: set[str],
28
- purpose: str) -> PARResponse:
29
+ purpose: str,
30
+ cache: HttpCache) -> PARResponse:
29
31
  _check_valid_purpose(purpose)
30
32
 
31
33
  checked_essential_claims = _ensure_mandatory_claims_present(essential_claims, voluntary_claims)
32
34
  claim_request = _generate_claims_request(checked_essential_claims, voluntary_claims)
33
35
 
34
- authorisation_server = get_auth_server_details(config, authorisation_server_id)
35
- issuer_metadata = get_issuer_metadata(authorisation_server)
36
+ authorisation_server = get_auth_server_details(config, authorisation_server_id, cache)
37
+ issuer_metadata = get_issuer_metadata(authorisation_server, cache)
36
38
 
37
39
  auth_server_url = issuer_metadata.issuer
38
40
  x_fapi_interaction_id = create_x_fapi_interaction_id()
@@ -22,12 +22,13 @@ from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientS
22
22
  from rp_python_sdk.sdk_config import SdkConfig
23
23
  from rp_python_sdk.setup_logger import logger
24
24
 
25
+ from rp_python_sdk.cache.http_cache import HttpCache
25
26
 
26
27
  def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_body: CallbackBody,
27
28
  original_code_verifier: str,
28
- original_state: str, nonce: str) -> TokenSet:
29
- authorisation_server = get_auth_server_details(config, authorisation_server_id)
30
- issuer_metadata = get_issuer_metadata(authorisation_server)
29
+ original_state: str, nonce: str, cache: HttpCache) -> TokenSet:
30
+ authorisation_server = get_auth_server_details(config, authorisation_server_id, cache)
31
+ issuer_metadata = get_issuer_metadata(authorisation_server, cache)
31
32
 
32
33
  if issuer_metadata.issuer != callback_body.iss:
33
34
  raise RelyingPartyClientSdkException(
@@ -104,7 +105,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
104
105
 
105
106
  token_input = from_dict(TokenInput, json.loads(response.text))
106
107
 
107
- jwks_response_body = _get_jwks_json(issuer_metadata, x_fapi_interaction_id)
108
+ jwks_response_body = _get_jwks_json(issuer_metadata, x_fapi_interaction_id, cache)
108
109
  allowed_algs = issuer_metadata.id_token_signing_alg_values_supported
109
110
 
110
111
  header, payload = _validate(token_input, jwks_response_body, allowed_algs, nonce, client_id, callback_body,
@@ -113,7 +114,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
113
114
  token_set = TokenSet(token_input, payload, x_fapi_interaction_id)
114
115
 
115
116
  if config.custom_config.enable_auto_compliance_verification:
116
- call_user_info(config, authorisation_server_id, token_set.token_input.access_token)
117
+ call_user_info(config, authorisation_server_id, token_set.token_input.access_token, cache)
117
118
 
118
119
  logger.info(
119
120
  f"Token response successfully retrieved from authorisation server {authorisation_server_id}, "
@@ -275,17 +276,31 @@ def _matching_alg(token_alg: str, key_element: dict) -> bool:
275
276
  return key_element.get("alg") is None or key_element.get("alg") == token_alg
276
277
 
277
278
 
278
- def _get_jwks_json(issuer_metadata: IssuerMetadata, x_fapi_interaction_id: str) -> Any:
279
+ def _get_jwks_json(issuer_metadata: IssuerMetadata, x_fapi_interaction_id: str, cache: HttpCache) -> Any:
280
+ url = issuer_metadata.jwks_uri
281
+
282
+ # Try cache first
283
+ cached = cache.get(url)
284
+ if cached:
285
+ logger.debug(f"Cache hit for URL: {url}")
286
+ return json.loads(cached)
287
+
288
+ logger.debug(f"Cache miss for URL: {url}")
289
+
279
290
  headers = {
280
291
  "Accept": "application/json",
281
292
  "x-fapi-interaction-id": x_fapi_interaction_id
282
293
  }
283
- response = requests.get(issuer_metadata.jwks_uri, headers=headers)
294
+ response = requests.get(url, headers=headers)
284
295
  response.raise_for_status()
285
296
 
286
297
  if not response.ok:
287
298
  raise RelyingPartyClientSdkException(
288
- f"Response code for JWKS to {issuer_metadata.jwks_uri} not successful, was: {response.status_code}, "
299
+ f"Response code for JWKS to {url} not successful, was: {response.status_code}, "
289
300
  f"x-fapi-interaction-id: {x_fapi_interaction_id}, response body: {response.text}")
290
301
 
302
+ # Cache the successful response
303
+ cache.put(url, response.text)
304
+ logger.debug(f"Cached response for URL: {url}, size: {len(response.text)} bytes")
305
+
291
306
  return response.json()
@@ -2,6 +2,7 @@ from typing import Any
2
2
 
3
3
  import requests
4
4
 
5
+ from rp_python_sdk.cache.http_cache import HttpCache
5
6
  from rp_python_sdk.endpoints.get_participants import get_issuer_metadata, \
6
7
  get_auth_server_details
7
8
  from rp_python_sdk.endpoints.util.fapi import create_x_fapi_interaction_id
@@ -10,12 +11,12 @@ from rp_python_sdk.sdk_config import SdkConfig
10
11
  from rp_python_sdk.setup_logger import logger
11
12
 
12
13
 
13
- def call_user_info(config: SdkConfig, authorisation_server_id: str, access_token: str) -> dict[str, Any]:
14
+ def call_user_info(config: SdkConfig, authorisation_server_id: str, access_token: str, cache: HttpCache) -> dict[str, Any]:
14
15
  x_fapi_interaction_id = create_x_fapi_interaction_id()
15
16
  logger.info(f"Making call to user info endpoint, x-fapi-interaction-id: {x_fapi_interaction_id}")
16
17
 
17
- authorisation_server = get_auth_server_details(config, authorisation_server_id)
18
- issuer_metadata = get_issuer_metadata(authorisation_server)
18
+ authorisation_server = get_auth_server_details(config, authorisation_server_id, cache)
19
+ issuer_metadata = get_issuer_metadata(authorisation_server, cache)
19
20
 
20
21
  user_info_endpoint = issuer_metadata.get_preferred_userinfo_endpoint()
21
22
 
@@ -0,0 +1,2 @@
1
+ def get_version_info() -> str:
2
+ return "1.3.0" # Version number
@@ -1,3 +1,4 @@
1
+ from rp_python_sdk.cache.http_cache import HttpCache
1
2
  from rp_python_sdk.endpoints.get_participants import get_participants, \
2
3
  retrieve_fallback_provider_participants
3
4
  from rp_python_sdk.endpoints.pushed_authorisation_request import send_pushed_authorisation_request
@@ -12,6 +13,7 @@ class RelyingPartyClientSdk:
12
13
 
13
14
  def __init__(self, config: SdkConfig):
14
15
  self.config = config
16
+ self.cache = HttpCache(config.custom_config)
15
17
 
16
18
  if config.custom_config.enable_auto_compliance_verification:
17
19
  logger.info(f"Auto Compliance Verification mode enabled, printing config information: "
@@ -21,10 +23,10 @@ class RelyingPartyClientSdk:
21
23
  f"registry_participants_uri={self.config.registry_participants_uri}")
22
24
 
23
25
  def get_participants(self) -> list[Participant]:
24
- return get_participants(self.config)
26
+ return get_participants(self.config, self.cache)
25
27
 
26
28
  def get_fallback_provider_participants(self) -> list[Participant]:
27
- return retrieve_fallback_provider_participants(self.config)
29
+ return retrieve_fallback_provider_participants(self.config, self.cache)
28
30
 
29
31
  def send_pushed_authorisation_request(self, authorisation_server_id: str,
30
32
  essential_claims: set[str] | None = None,
@@ -36,9 +38,9 @@ class RelyingPartyClientSdk:
36
38
  checked_purpose = purpose if purpose else self.config.custom_config.purpose
37
39
 
38
40
  return send_pushed_authorisation_request(self.config, authorisation_server_id, checked_essential_claims,
39
- checked_voluntary_claims, checked_purpose)
41
+ checked_voluntary_claims, checked_purpose, self.cache)
40
42
 
41
43
  def retrieve_tokens(self, authorisation_server_id: str, callback_body: CallbackBody, original_code_verifier: str,
42
44
  original_state: str, nonce: str) -> TokenSet:
43
45
  return retrieve_tokens(self.config, authorisation_server_id, callback_body, original_code_verifier,
44
- original_state, nonce)
46
+ original_state, nonce, self.cache)
@@ -23,6 +23,10 @@ class CustomConfig:
23
23
  required_claims: List[str] = field(default_factory=list)
24
24
  required_participant_certifications: List['CertificationFilter'] = field(default_factory=list)
25
25
  timeout_in_seconds: int = 10
26
+ cache_enabled: bool = True
27
+ cache_ttl_minutes: int = 10
28
+ cache_max_entries: int = 100
29
+ cache_max_element_size_bytes: int = 5242880
26
30
 
27
31
  def __post_init__(self) -> None:
28
32
  if self.include_uncertified_participants:
@@ -1,2 +0,0 @@
1
- def get_version_info() -> str:
2
- return "1.1.7" # Version number