rp_python_sdk 0.9.0__tar.gz → 0.11.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rp_python_sdk
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Python SDK for Relying Parties to enable simple integration with the Digital Identity ecosystem
5
5
  License: Proprietary
6
6
  Author: Erik Pragt
@@ -426,6 +426,12 @@ poetry publish
426
426
 
427
427
  ## Release Notes
428
428
 
429
+ ### 0.11.0 (Nov 29, 2024)
430
+ - Update to use Authorisation Server issuer attribute as `aud` value when retrieving tokens.
431
+
432
+ ### 0.10.0 (Oct 24, 2024)
433
+ - JWT signature verification for id_token in the retrieve_tokens step.
434
+
429
435
  ### 0.9.0 (Oct 18, 2024)
430
436
  - Added support for Python 3.13
431
437
  - Added support for `payment` ApiResources
@@ -407,6 +407,12 @@ poetry publish
407
407
 
408
408
  ## Release Notes
409
409
 
410
+ ### 0.11.0 (Nov 29, 2024)
411
+ - Update to use Authorisation Server issuer attribute as `aud` value when retrieving tokens.
412
+
413
+ ### 0.10.0 (Oct 24, 2024)
414
+ - JWT signature verification for id_token in the retrieve_tokens step.
415
+
410
416
  ### 0.9.0 (Oct 18, 2024)
411
417
  - Added support for Python 3.13
412
418
  - Added support for `payment` ApiResources
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rp_python_sdk"
3
- version = "0.9.0"
3
+ version = "0.11.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"
@@ -21,7 +21,7 @@ httpx = "^0.27.2"
21
21
  [tool.poetry.group.dev.dependencies]
22
22
  black = "^24.10.0"
23
23
  pre-commit = "^3.8.0"
24
- mypy = "^1.11.2"
24
+ mypy = "^1.13.0"
25
25
  requests-mock = "^1.12.1"
26
26
  freezegun = "^1.4.0"
27
27
  flake8 = "^7.1.1"
@@ -8,6 +8,9 @@ import requests
8
8
  from joserfc import jws
9
9
  from joserfc.jwk import RSAKey
10
10
  from joserfc.jws import JWSRegistry
11
+ from authlib.jose import JsonWebToken
12
+ from authlib.jose.errors import BadSignatureError, DecodeError
13
+ from authlib.jose.util import extract_header
11
14
 
12
15
  from rp_python_sdk.endpoints.get_participants import get_issuer_metadata, \
13
16
  get_auth_server_details
@@ -37,15 +40,16 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
37
40
  f"{authorisation_server.customer_friendly_name}, x-fapi-interaction-id: {x_fapi_interaction_id}")
38
41
 
39
42
  token_endpoint = issuer_metadata.get_preferred_token_endpoint()
43
+ issuer = issuer_metadata.issuer
40
44
 
41
45
  if callback_body.state != original_state:
42
46
  raise RelyingPartyClientSdkException(
43
47
  f"Callback response state value does not match state, callback state: {callback_body.state}, "
44
48
  f"state {original_state}, x-fapi-interaction-id: {x_fapi_interaction_id}")
45
49
 
46
- if callback_body.iss != issuer_metadata.issuer:
50
+ if callback_body.iss != issuer:
47
51
  raise RelyingPartyClientSdkException(
48
- f"iss mismatch, expected {issuer_metadata.issuer} , got: {callback_body.iss}")
52
+ f"iss mismatch, expected {issuer}, got: {callback_body.iss}")
49
53
 
50
54
  redirect_uri = config.application_redirect_uri
51
55
  client_id = config.client_id
@@ -56,7 +60,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
56
60
 
57
61
  client_assertion_payload = {
58
62
  'sub': client_id,
59
- 'aud': token_endpoint,
63
+ 'aud': issuer,
60
64
  'iss': client_id,
61
65
  'exp': exp,
62
66
  'jti': jti,
@@ -103,7 +107,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
103
107
  jwks_response_body = _get_jwks_json(issuer_metadata, x_fapi_interaction_id)
104
108
  allowed_algs = issuer_metadata.id_token_signing_alg_values_supported
105
109
 
106
- header, payload = _validate(token_input, jwks_response_body, allowed_algs, nonce, client_id, callback_body)
110
+ header, payload = _validate(token_input, jwks_response_body, allowed_algs, nonce, client_id, callback_body, issuer_metadata.issuer)
107
111
 
108
112
  token_set = TokenSet(token_input, payload, x_fapi_interaction_id)
109
113
 
@@ -119,7 +123,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
119
123
 
120
124
  def _validate(token_input: TokenInput, jwks_response_body: dict[str, Any], allowed_algs: list[str],
121
125
  nonce: str, client_id: str,
122
- callback_body: CallbackBody) -> Tuple[dict[str, Any], dict[str, Any]]:
126
+ callback_body: CallbackBody, issuer: str) -> Tuple[dict[str, Any], dict[str, Any]]:
123
127
  """
124
128
  Decode and parse an ID token into JSON objects for header and payload.
125
129
 
@@ -129,18 +133,18 @@ def _validate(token_input: TokenInput, jwks_response_body: dict[str, Any], allow
129
133
  :param nonce
130
134
  :param client_id
131
135
  :param callback_body
136
+ :param issuer
132
137
  :return: two dictionaries for header and payload
133
138
  """
134
139
  id_token = token_input.id_token
135
- header_base64 = id_token.split('.')[0]
136
- header = _decode_base64(header_base64)
137
- header_json = json.loads(header)
140
+ header_json = _get_unverified_header(id_token)
138
141
 
139
142
  payload_base64 = id_token.split('.')[1]
140
143
  payload = _decode_base64(payload_base64)
141
144
  payload_json = json.loads(payload)
142
145
 
143
- _validate_header(header_json, jwks_response_body, allowed_algs, id_token)
146
+ _validate_header(header_json, allowed_algs)
147
+ _validate_signature(issuer, client_id, allowed_algs, id_token, jwks_response_body)
144
148
  _validate_payload(payload_json, nonce, callback_body, client_id)
145
149
 
146
150
  return header_json, payload_json
@@ -193,6 +197,8 @@ def _validate_payload(json_payload: dict[str, Any], nonce: str, callback_body: C
193
197
  if json_payload['aud'] != client_id:
194
198
  raise RelyingPartyClientSdkException('aud claim in token response does not match client ID')
195
199
 
200
+ def _get_unverified_header(id_token:str) -> Any:
201
+ return extract_header(id_token.split('.')[0].encode(), DecodeError)
196
202
 
197
203
  def _decode_base64(base64_str: str) -> str:
198
204
  """
@@ -204,51 +210,61 @@ def _decode_base64(base64_str: str) -> str:
204
210
  return decoded_bytes.decode('utf-8')
205
211
 
206
212
 
207
- def _validate_header(json_header, jwks, allowed_algs, id_token: str) -> None:
213
+ def _validate_header(json_header: dict, allowed_algs: list) -> None:
208
214
  if 'alg' not in json_header or json_header['alg'] == 'none':
209
215
  raise RelyingPartyClientSdkException("no alg value in the token response jwt header")
210
216
 
211
217
  if 'kid' not in json_header:
212
218
  raise RelyingPartyClientSdkException("no kid value in the token response jwt header")
213
219
 
214
- kid = json_header['kid']
215
- jwk = None
216
-
217
- for key_element in jwks.get('keys', []):
218
- if _matching_kid(kid, key_element) and _none_or_matching_alg(json_header, key_element):
219
- jwk = key_element
220
- break
221
-
222
- if jwk is None:
223
- raise RelyingPartyClientSdkException(
224
- f"kid provided does not match any key in the jwks for specified algorithm in "
225
- f"token header: {json_header['alg']}")
226
-
227
220
  found_alg = any(alg.lower() == json_header['alg'].lower() for alg in allowed_algs)
228
221
  if not found_alg:
229
222
  raise RelyingPartyClientSdkException(
230
- "alg value in id token header is not one of the supported algorithms listed on the well-known")
231
-
232
- # try:
233
- # validate_signature(token_input.get('id_token'), jwk)
234
- # except (JOSEError, ValueError) as e:
235
- # raise RelyingPartyClientSdkException(f"exception while validation jwt signature: {e}")
223
+ f"alg value in id token header is not one of the supported algorithms listed on the well-known. "
224
+ f"Provided alg {json_header['alg']}")
236
225
 
237
-
238
- def _validate_signature(self, id_token, jwk):
239
- # Implement JWT signature validation based on the jwk provided and the library you're using
240
- pass
241
-
242
-
243
- def _matching_kid(kid: str, key_element) -> bool:
244
- # Implement this to match the 'kid' from the JWT with one from the JWKS
226
+ def _find_key_in_jwks(kid: str, token_alg: str, jwks: dict) -> Any:
227
+ for key_element in jwks.get('keys', []):
228
+ if _matching_kid(kid, key_element) and _matching_alg(token_alg, key_element) and _matching_use(key_element):
229
+ return key_element
230
+ return None
231
+
232
+ def _validate_signature(issuer: str, audience: str, allowed_alg: list, id_token: str, jwks: dict) -> None:
233
+ try:
234
+ # Pub key extraction
235
+ header = _get_unverified_header(id_token)
236
+ kid = header['kid']
237
+ alg = header['alg']
238
+ jwk = _find_key_in_jwks(kid, alg, jwks)
239
+
240
+ if jwk is None:
241
+ raise RelyingPartyClientSdkException(
242
+ f"kid and alg provided does not match any key in the jwks for specified algorithm in "
243
+ f"token header, kid: {kid}, alg: {alg}")
244
+
245
+ # Decode JWT with signature verification
246
+ claims_options = {
247
+ "iss": {"essential": True, "value": issuer},
248
+ "aud": {"essential": True, "value": audience}
249
+ }
250
+ jwt = JsonWebToken(allowed_alg)
251
+ claims = jwt.decode(id_token, key=jwk, claims_options=claims_options)
252
+ claims.validate()
253
+ logger.info("JWT signature is valid")
254
+ except BadSignatureError as e:
255
+ raise RelyingPartyClientSdkException(f"Invalid JWT signature: {e}, token data: {e.result}")
256
+ except Exception as e:
257
+ logger.debug(f"Invalid JWT token: {id_token}")
258
+ raise RelyingPartyClientSdkException(f"Invalid JWT signature: {e}")
259
+
260
+ def _matching_use(key_element: dict) -> bool:
261
+ return key_element.get('use') == "sig"
262
+
263
+ def _matching_kid(kid: str, key_element: dict) -> bool:
245
264
  return key_element.get('kid') == kid
246
265
 
247
-
248
- def _none_or_matching_alg(json_header, key_element) -> bool:
249
- # Implement this to check if the 'alg' in JWT matches the one in the key element, if required
250
- return True
251
-
266
+ def _matching_alg(token_alg: str, key_element: dict) -> bool:
267
+ return key_element.get("alg") is None or key_element.get("alg") == token_alg
252
268
 
253
269
  def _get_jwks_json(issuer_metadata: IssuerMetadata, x_fapi_interaction_id: str) -> Any:
254
270
  headers = {
@@ -263,4 +279,4 @@ def _get_jwks_json(issuer_metadata: IssuerMetadata, x_fapi_interaction_id: str)
263
279
  f"Response code for JWKS to {issuer_metadata.jwks_uri} not successful, was: {response.status_code}, "
264
280
  f"x-fapi-interaction-id: {x_fapi_interaction_id}, response body: {response.text}")
265
281
 
266
- return response.json()
282
+ return response.json()
@@ -32,7 +32,7 @@ def from_dict(data_class: Type[T], data: Union[dict, list], pascal_case=False) -
32
32
  for field, field_type in fieldtypes.items():
33
33
  # Convert the snake_case field name to PascalCase to match the JSON key
34
34
  json_field = field
35
- if (pascal_case):
35
+ if pascal_case:
36
36
  json_field = snake_to_pascal(field)
37
37
 
38
38
  # Fetch the corresponding value from the JSON data