rp_python_sdk 0.9.0__tar.gz → 0.10.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.10.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,9 @@ poetry publish
426
426
 
427
427
  ## Release Notes
428
428
 
429
+ ### 0.10.0 (Oct 24, 2024)
430
+ - JWT signature verification for id_token in the retrieve_tokens step.
431
+
429
432
  ### 0.9.0 (Oct 18, 2024)
430
433
  - Added support for Python 3.13
431
434
  - Added support for `payment` ApiResources
@@ -407,6 +407,9 @@ poetry publish
407
407
 
408
408
  ## Release Notes
409
409
 
410
+ ### 0.10.0 (Oct 24, 2024)
411
+ - JWT signature verification for id_token in the retrieve_tokens step.
412
+
410
413
  ### 0.9.0 (Oct 18, 2024)
411
414
  - Added support for Python 3.13
412
415
  - 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.10.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
@@ -103,7 +106,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
103
106
  jwks_response_body = _get_jwks_json(issuer_metadata, x_fapi_interaction_id)
104
107
  allowed_algs = issuer_metadata.id_token_signing_alg_values_supported
105
108
 
106
- header, payload = _validate(token_input, jwks_response_body, allowed_algs, nonce, client_id, callback_body)
109
+ header, payload = _validate(token_input, jwks_response_body, allowed_algs, nonce, client_id, callback_body, issuer_metadata.issuer)
107
110
 
108
111
  token_set = TokenSet(token_input, payload, x_fapi_interaction_id)
109
112
 
@@ -119,7 +122,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
119
122
 
120
123
  def _validate(token_input: TokenInput, jwks_response_body: dict[str, Any], allowed_algs: list[str],
121
124
  nonce: str, client_id: str,
122
- callback_body: CallbackBody) -> Tuple[dict[str, Any], dict[str, Any]]:
125
+ callback_body: CallbackBody, issuer: str) -> Tuple[dict[str, Any], dict[str, Any]]:
123
126
  """
124
127
  Decode and parse an ID token into JSON objects for header and payload.
125
128
 
@@ -129,18 +132,18 @@ def _validate(token_input: TokenInput, jwks_response_body: dict[str, Any], allow
129
132
  :param nonce
130
133
  :param client_id
131
134
  :param callback_body
135
+ :param issuer
132
136
  :return: two dictionaries for header and payload
133
137
  """
134
138
  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)
139
+ header_json = _get_unverified_header(id_token)
138
140
 
139
141
  payload_base64 = id_token.split('.')[1]
140
142
  payload = _decode_base64(payload_base64)
141
143
  payload_json = json.loads(payload)
142
144
 
143
- _validate_header(header_json, jwks_response_body, allowed_algs, id_token)
145
+ _validate_header(header_json, allowed_algs)
146
+ _validate_signature(issuer, client_id, allowed_algs, id_token, jwks_response_body)
144
147
  _validate_payload(payload_json, nonce, callback_body, client_id)
145
148
 
146
149
  return header_json, payload_json
@@ -193,6 +196,8 @@ def _validate_payload(json_payload: dict[str, Any], nonce: str, callback_body: C
193
196
  if json_payload['aud'] != client_id:
194
197
  raise RelyingPartyClientSdkException('aud claim in token response does not match client ID')
195
198
 
199
+ def _get_unverified_header(id_token:str) -> Any:
200
+ return extract_header(id_token.split('.')[0].encode(), DecodeError)
196
201
 
197
202
  def _decode_base64(base64_str: str) -> str:
198
203
  """
@@ -204,51 +209,61 @@ def _decode_base64(base64_str: str) -> str:
204
209
  return decoded_bytes.decode('utf-8')
205
210
 
206
211
 
207
- def _validate_header(json_header, jwks, allowed_algs, id_token: str) -> None:
212
+ def _validate_header(json_header: dict, allowed_algs: list) -> None:
208
213
  if 'alg' not in json_header or json_header['alg'] == 'none':
209
214
  raise RelyingPartyClientSdkException("no alg value in the token response jwt header")
210
215
 
211
216
  if 'kid' not in json_header:
212
217
  raise RelyingPartyClientSdkException("no kid value in the token response jwt header")
213
218
 
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
219
  found_alg = any(alg.lower() == json_header['alg'].lower() for alg in allowed_algs)
228
220
  if not found_alg:
229
221
  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}")
222
+ f"alg value in id token header is not one of the supported algorithms listed on the well-known. "
223
+ f"Provided alg {json_header['alg']}")
236
224
 
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
225
+ def _find_key_in_jwks(kid: str, token_alg: str, jwks: dict) -> Any:
226
+ for key_element in jwks.get('keys', []):
227
+ if _matching_kid(kid, key_element) and _matching_alg(token_alg, key_element) and _matching_use(key_element):
228
+ return key_element
229
+ return None
230
+
231
+ def _validate_signature(issuer: str, audience: str, allowed_alg: list, id_token: str, jwks: dict) -> None:
232
+ try:
233
+ # Pub key extraction
234
+ header = _get_unverified_header(id_token)
235
+ kid = header['kid']
236
+ alg = header['alg']
237
+ jwk = _find_key_in_jwks(kid, alg, jwks)
238
+
239
+ if jwk is None:
240
+ raise RelyingPartyClientSdkException(
241
+ f"kid and alg provided does not match any key in the jwks for specified algorithm in "
242
+ f"token header, kid: {kid}, alg: {alg}")
243
+
244
+ # Decode JWT with signature verification
245
+ claims_options = {
246
+ "iss": {"essential": True, "value": issuer},
247
+ "aud": {"essential": True, "value": audience}
248
+ }
249
+ jwt = JsonWebToken(allowed_alg)
250
+ claims = jwt.decode(id_token, key=jwk, claims_options=claims_options)
251
+ claims.validate()
252
+ logger.info("JWT signature is valid")
253
+ except BadSignatureError as e:
254
+ raise RelyingPartyClientSdkException(f"Invalid JWT signature: {e}, token data: {e.result}")
255
+ except Exception as e:
256
+ logger.debug(f"Invalid JWT token: {id_token}")
257
+ raise RelyingPartyClientSdkException(f"Invalid JWT signature: {e}")
258
+
259
+ def _matching_use(key_element: dict) -> bool:
260
+ return key_element.get('use') == "sig"
261
+
262
+ def _matching_kid(kid: str, key_element: dict) -> bool:
245
263
  return key_element.get('kid') == kid
246
264
 
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
-
265
+ def _matching_alg(token_alg: str, key_element: dict) -> bool:
266
+ return key_element.get("alg") is None or key_element.get("alg") == token_alg
252
267
 
253
268
  def _get_jwks_json(issuer_metadata: IssuerMetadata, x_fapi_interaction_id: str) -> Any:
254
269
  headers = {
@@ -263,4 +278,4 @@ def _get_jwks_json(issuer_metadata: IssuerMetadata, x_fapi_interaction_id: str)
263
278
  f"Response code for JWKS to {issuer_metadata.jwks_uri} not successful, was: {response.status_code}, "
264
279
  f"x-fapi-interaction-id: {x_fapi_interaction_id}, response body: {response.text}")
265
280
 
266
- return response.json()
281
+ 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