rp_python_sdk 0.7.0__tar.gz → 0.9.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,3 +1,22 @@
1
+ Metadata-Version: 2.1
2
+ Name: rp_python_sdk
3
+ Version: 0.9.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
+ Requires-Dist: authlib (>=1.3.2,<2.0.0)
14
+ Requires-Dist: cffi (>=1.17.1,<2.0.0)
15
+ Requires-Dist: cryptography (>=43.0.1,<44.0.0)
16
+ Requires-Dist: joserfc (>=1.0.0,<2.0.0)
17
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
18
+ Description-Content-Type: text/markdown
19
+
1
20
  # Python SDK for Relying Parties
2
21
 
3
22
  [![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)
@@ -407,6 +426,29 @@ poetry publish
407
426
 
408
427
  ## Release Notes
409
428
 
429
+ ### 0.9.0 (Oct 18, 2024)
430
+ - Added support for Python 3.13
431
+ - Added support for `payment` ApiResources
432
+ - Updated all dependencies
433
+
434
+ ### 0.8.0 (Sep 3, 2024)
435
+ - Breaking change: When `essential_claims` was empty it used the `default_claims` list. That logic has been removed.
436
+
437
+ ```python
438
+ def send_pushed_authorisation_request(config: SdkConfig,
439
+ authorisation_server_id: str,
440
+ essential_claims: set[str],
441
+ voluntary_claims: set[str],
442
+ purpose: str) -> PARResponse:
443
+ # this has been removed
444
+ if len(essential_claims) == 0:
445
+ essential_claims = default_claims
446
+ ...
447
+ ```
448
+
449
+ ### 0.7.1 (June 28, 2024)
450
+ - Made `scope` field optional in accordance with rfc-6749.
451
+
410
452
  ### 0.7.0 (June 14, 2024)
411
453
  - Changed purpose field to allow 3 to 300 characters (up from 50)
412
454
 
@@ -426,4 +468,5 @@ poetry publish
426
468
  - Implemented all endpoints
427
469
 
428
470
  ### 0.1.0 (March 27, 2024)
429
- - Initial version
471
+ - Initial version
472
+
@@ -1,19 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: rp_python_sdk
3
- Version: 0.7.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
- Requires-Dist: authlib (>=1.3.0,<2.0.0)
13
- Requires-Dist: dataclass-wizard (>=0.22.3,<0.23.0)
14
- Requires-Dist: requests (>=2.31.0,<3.0.0)
15
- Description-Content-Type: text/markdown
16
-
17
1
  # Python SDK for Relying Parties
18
2
 
19
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)
@@ -423,6 +407,29 @@ poetry publish
423
407
 
424
408
  ## Release Notes
425
409
 
410
+ ### 0.9.0 (Oct 18, 2024)
411
+ - Added support for Python 3.13
412
+ - Added support for `payment` ApiResources
413
+ - Updated all dependencies
414
+
415
+ ### 0.8.0 (Sep 3, 2024)
416
+ - Breaking change: When `essential_claims` was empty it used the `default_claims` list. That logic has been removed.
417
+
418
+ ```python
419
+ def send_pushed_authorisation_request(config: SdkConfig,
420
+ authorisation_server_id: str,
421
+ essential_claims: set[str],
422
+ voluntary_claims: set[str],
423
+ purpose: str) -> PARResponse:
424
+ # this has been removed
425
+ if len(essential_claims) == 0:
426
+ essential_claims = default_claims
427
+ ...
428
+ ```
429
+
430
+ ### 0.7.1 (June 28, 2024)
431
+ - Made `scope` field optional in accordance with rfc-6749.
432
+
426
433
  ### 0.7.0 (June 14, 2024)
427
434
  - Changed purpose field to allow 3 to 300 characters (up from 50)
428
435
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "rp_python_sdk"
3
- version = "0.7.0"
3
+ version = "0.9.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"
@@ -8,20 +8,23 @@ readme = "README.md"
8
8
 
9
9
  [tool.poetry.dependencies]
10
10
  python = "^3.12"
11
- dataclass-wizard = "^0.22.3"
12
- requests = "^2.31.0"
13
- authlib = "^1.3.0"
11
+ requests = "^2.32.3"
12
+ authlib = "^1.3.2"
13
+ cryptography = "^43.0.1"
14
+ joserfc = "^1.0.0"
15
+ cffi = "^1.17.1"
14
16
 
15
17
  [tool.poetry.group.test.dependencies]
16
- pytest = "^8.0.0"
17
- httpx = "^0.27.0"
18
+ pytest = "^8.3.3"
19
+ httpx = "^0.27.2"
18
20
 
19
21
  [tool.poetry.group.dev.dependencies]
20
- black = "^24.3.0"
21
- pre-commit = "^3.5.0"
22
- mypy = "^1.9.0"
22
+ black = "^24.10.0"
23
+ pre-commit = "^3.8.0"
24
+ mypy = "^1.11.2"
23
25
  requests-mock = "^1.12.1"
24
26
  freezegun = "^1.4.0"
27
+ flake8 = "^7.1.1"
25
28
 
26
29
  [build-system]
27
30
  requires = ["poetry-core"]
@@ -1,7 +1,9 @@
1
1
  from datetime import date
2
2
 
3
3
  import requests
4
+ import json
4
5
 
6
+ from rp_python_sdk.endpoints.util.mapping import from_dict
5
7
  from rp_python_sdk.filters.participant_filters import remove_out_of_date_certifications, \
6
8
  remove_unofficial_certifications, filter_for_fallback_identity_service_providers, \
7
9
  remove_participants_without_auth_servers, remove_fallback_identity_service_provider, \
@@ -65,7 +67,8 @@ def _retrieve_full_participants_list(config: SdkConfig) -> list[Participant]:
65
67
  raise RelyingPartyClientSdkException(
66
68
  f"Call to identity provider endpoint failed with code: {response.status_code}")
67
69
 
68
- participant_list: list[Participant] = Participant.from_json(response.text)
70
+ # participant_list: list[Participant] = Participant.from_json(response.text)
71
+ participant_list: list[Participant] = from_dict(Participant, json.loads(response.text), pascal_case=True)
69
72
 
70
73
  logger.info(f"Retrieved identity providers, num orgs found: {len(participant_list)}")
71
74
  return participant_list
@@ -83,14 +86,14 @@ def get_auth_server_details(config: SdkConfig, authorisation_server_id: str) ->
83
86
 
84
87
 
85
88
  def get_issuer_metadata(authorisation_server: AuthorisationServer):
86
- auth_server_issuer = authorisation_server.open_id_discovery_document
89
+ auth_server_issuer = authorisation_server.open_i_d_discovery_document
87
90
 
88
91
  try:
89
92
  logger.info(f"Getting discovery document from {auth_server_issuer}")
90
93
  response = requests.get(auth_server_issuer)
91
94
  response.raise_for_status() # Raises an exception for 4XX or 5XX errors
92
95
 
93
- return IssuerMetadata.from_json(response.text)
96
+ return from_dict(IssuerMetadata, json.loads(response.text))
94
97
 
95
98
  except requests.exceptions.HTTPError as http_err:
96
99
  raise RelyingPartyClientSdkException(
@@ -16,9 +16,6 @@ from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientS
16
16
  from rp_python_sdk.sdk_config import SdkConfig
17
17
  from rp_python_sdk.setup_logger import logger
18
18
 
19
- default_claims = {"name", "given_name", "middle_name", "family_name", "phone_number", "email", "address", "birthdate",
20
- "txn"}
21
-
22
19
  extended_claims = {"over16", "over18", "over21", "over25", "over65", "beneficiary_account_au",
23
20
  "beneficiary_account_au_payid", "beneficiary_account_international"}
24
21
 
@@ -28,9 +25,6 @@ def send_pushed_authorisation_request(config: SdkConfig,
28
25
  essential_claims: set[str],
29
26
  voluntary_claims: set[str],
30
27
  purpose: str) -> PARResponse:
31
- if len(essential_claims) == 0:
32
- essential_claims = default_claims
33
-
34
28
  _check_valid_purpose(purpose)
35
29
 
36
30
  checked_essential_claims = _ensure_mandatory_claims_present(essential_claims, voluntary_claims)
@@ -117,10 +111,12 @@ def send_pushed_authorisation_request(config: SdkConfig,
117
111
  cert=(config.transport_pem, config.transport_key),
118
112
  verify=config.ca_pem)
119
113
 
120
- log_x_fapi_interaction_id_matches(authorisation_server, "send_pushed_authorisation_request", x_fapi_interaction_id, response)
114
+ log_x_fapi_interaction_id_matches(authorisation_server, "send_pushed_authorisation_request",
115
+ x_fapi_interaction_id, response)
121
116
 
122
117
  if not response.ok:
123
- raise RelyingPartyClientSdkException(f"Response code for PAR to {authorisation_server_id} not successful, was: {response.status_code} x-fapi-interaction-id: {x_fapi_interaction_id}")
118
+ raise RelyingPartyClientSdkException(f"Response code for PAR to {authorisation_server_id} not successful, "
119
+ f"was: {response.status_code} x-fapi-interaction-id: {x_fapi_interaction_id}")
124
120
 
125
121
  request_uri = response.json()['request_uri']
126
122
  logger.debug(f"PAR was sent, request_uri='{request_uri}'")
@@ -138,7 +134,8 @@ def _check_valid_purpose(purpose) -> None:
138
134
  contains_illegal_char = any(char in purpose for char in illegal_chars)
139
135
  if contains_illegal_char:
140
136
  raise RelyingPartyClientSdkException(
141
- f"Purpose cannot contain any of the following characters: {', '.join(illegal_chars)}, purpose supplied: [{purpose}]")
137
+ f"Purpose cannot contain any of the following characters: {', '.join(illegal_chars)}, "
138
+ f"purpose supplied: [{purpose}]")
142
139
 
143
140
 
144
141
  def _ensure_mandatory_claims_present(essential_claims: set[str], voluntary_claims: set[str]) -> set[str]:
@@ -5,12 +5,15 @@ import uuid
5
5
  from typing import Tuple, Any
6
6
 
7
7
  import requests
8
- from authlib.jose import JsonWebSignature
8
+ from joserfc import jws
9
+ from joserfc.jwk import RSAKey
10
+ from joserfc.jws import JWSRegistry
9
11
 
10
12
  from rp_python_sdk.endpoints.get_participants import get_issuer_metadata, \
11
13
  get_auth_server_details
12
14
  from rp_python_sdk.endpoints.user_info import call_user_info
13
15
  from rp_python_sdk.endpoints.util.fapi import create_x_fapi_interaction_id, log_x_fapi_interaction_id_matches
16
+ from rp_python_sdk.endpoints.util.mapping import from_dict
14
17
  from rp_python_sdk.model import CallbackBody, TokenSet, TokenInput, IssuerMetadata
15
18
  from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientSdkException
16
19
  from rp_python_sdk.sdk_config import SdkConfig
@@ -60,18 +63,19 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
60
63
  'iat': iat,
61
64
  }
62
65
 
63
- jws = JsonWebSignature()
64
- header = {'alg': 'PS256', 'kid': config.signing_kid}
66
+ # See https://jose.authlib.org/en/guide/registry/
67
+ registry = JWSRegistry(algorithms=["PS256"])
65
68
 
66
- client_assertion_jwt = jws.serialize_compact(header, json.dumps(client_assertion_payload),
67
- config.signing_key)
69
+ key = RSAKey.import_key(config.signing_key)
70
+ header = {'alg': 'PS256', 'kid': config.signing_kid}
71
+ client_assertion_jwt = jws.serialize_compact(header, json.dumps(client_assertion_payload), key, registry = registry)
68
72
 
69
73
  # Data payload
70
74
  data = {
71
75
  "grant_type": "authorization_code",
72
76
  "code": callback_body.code,
73
77
  "redirect_uri": redirect_uri,
74
- "client_assertion": client_assertion_jwt.decode("utf-8"),
78
+ "client_assertion": client_assertion_jwt,
75
79
  "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
76
80
  "code_verifier": original_code_verifier
77
81
  }
@@ -94,7 +98,7 @@ def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_bo
94
98
  f"Response code for token endpoint to {token_endpoint} not successful, was: {response.status_code}, "
95
99
  f"x-fapi-interaction-id: {x_fapi_interaction_id}, response body: " + response.text)
96
100
 
97
- token_input = TokenInput.from_json(response.text)
101
+ token_input = from_dict(TokenInput, json.loads(response.text))
98
102
 
99
103
  jwks_response_body = _get_jwks_json(issuer_metadata, x_fapi_interaction_id)
100
104
  allowed_algs = issuer_metadata.id_token_signing_alg_values_supported
@@ -0,0 +1,54 @@
1
+ from typing import Type, TypeVar, Union, List, get_type_hints
2
+
3
+ T = TypeVar('T')
4
+
5
+
6
+ def snake_to_pascal(name: str) -> str:
7
+ """
8
+ Converts a snake_case string to PascalCase.
9
+ Example: organisation_id -> OrganisationId
10
+ """
11
+ return ''.join(word.capitalize() for word in name.split('_'))
12
+
13
+
14
+ def from_dict(data_class: Type[T], data: Union[dict, list], pascal_case=False) -> Union[T, List[T]]:
15
+ """
16
+ Recursively maps a dictionary or a list of dictionaries to a dataclass or a list of dataclasses.
17
+ Handles key transformations from snake_case to PascalCase, and resolves forward references.
18
+ """
19
+ if isinstance(data, list):
20
+ # Map each item in the list to the dataclass
21
+ return [from_dict(data_class, item, pascal_case) for item in data]
22
+
23
+ if not isinstance(data, dict):
24
+ raise ValueError(
25
+ f"Expected a dictionary to map to {data_class.__name__}, but got {type(data).__name__}: {data}")
26
+
27
+ # Get the fields of the dataclass, resolving forward references
28
+ fieldtypes = get_type_hints(data_class)
29
+
30
+ # Prepare the arguments for the dataclass constructor
31
+ data_class_kwargs = {}
32
+ for field, field_type in fieldtypes.items():
33
+ # Convert the snake_case field name to PascalCase to match the JSON key
34
+ json_field = field
35
+ if (pascal_case):
36
+ json_field = snake_to_pascal(field)
37
+
38
+ # Fetch the corresponding value from the JSON data
39
+ field_value = data.get(json_field)
40
+
41
+ if isinstance(field_value, list):
42
+ inner_type = field_type.__args__[0]
43
+ data_class_kwargs[field] = [from_dict(inner_type, item, pascal_case) if isinstance(item, dict) else item for
44
+ item in field_value]
45
+ elif isinstance(field_value, dict):
46
+ data_class_kwargs[field] = from_dict(field_type, field_value, pascal_case)
47
+ else:
48
+ # Handle authorisation_servers case
49
+ if field == "authorisation_servers" and field_value is None:
50
+ data_class_kwargs[field] = []
51
+ else:
52
+ data_class_kwargs[field] = field_value
53
+
54
+ return data_class(**data_class_kwargs)
@@ -96,4 +96,3 @@ def filter_for_fallback_identity_service_providers(participants: List[Participan
96
96
  def to_date(date_string: str):
97
97
  date_format = "%d/%m/%Y"
98
98
  return datetime.strptime(date_string, date_format).date()
99
-
@@ -2,11 +2,8 @@ from dataclasses import dataclass
2
2
  from typing import Any, Dict, Optional
3
3
  from typing import List
4
4
 
5
- from dataclass_wizard import JSONWizard
6
-
7
-
8
5
  @dataclass
9
- class Participant(JSONWizard):
6
+ class Participant:
10
7
  organisation_id: str
11
8
  status: str
12
9
  organisation_name: str
@@ -29,7 +26,7 @@ class Participant(JSONWizard):
29
26
 
30
27
 
31
28
  @dataclass
32
- class AuthorisationServerCertification(JSONWizard):
29
+ class AuthorisationServerCertification:
33
30
  certification_start_date: str # date
34
31
  certification_expiration_date: str # date
35
32
  certification_id: str
@@ -42,31 +39,49 @@ class AuthorisationServerCertification(JSONWizard):
42
39
 
43
40
 
44
41
  @dataclass
45
- class OrgDomainClaim(JSONWizard):
42
+ class OrgDomainClaim:
46
43
  authorisation_domain_name: str
47
44
  authority_name: str
48
45
  registration_id: str
49
46
  status: str
50
47
 
48
+ @dataclass
49
+ class ApiDiscoveryEndpoint:
50
+ api_discovery_id: str
51
+ api_endpoint: str
52
+
53
+ @dataclass
54
+ class ApiResource:
55
+ api_resource_id: str
56
+ api_version: str
57
+ api_discovery_endpoints: List[ApiDiscoveryEndpoint]
58
+ family_complete: bool
59
+ api_certification_uri: Optional[str]
60
+ certification_status: Optional[str]
61
+ # should be a date
62
+ certification_start_date: str
63
+ # should be a date
64
+ certification_expiration_date: str
65
+ api_family_type: Optional[str]
51
66
 
52
67
  @dataclass
53
- class AuthorisationServer(JSONWizard):
68
+ class AuthorisationServer:
54
69
  authorisation_server_id: str
55
- api_resources: List[str]
70
+ api_resources: List[ApiResource]
56
71
  authorisation_server_certifications: List[AuthorisationServerCertification]
57
72
  customer_friendly_description: str
58
73
  customer_friendly_logo_uri: str
59
74
  customer_friendly_name: str
60
75
  developer_portal_uri: str
61
76
  terms_of_service_uri: str
62
- open_id_discovery_document: str
77
+ open_i_d_discovery_document: str
63
78
  issuer: str
64
79
  payload_signing_cert_location_uri: str
65
80
  parent_authorisation_server_id: str
66
81
 
67
82
 
68
83
  @dataclass
69
- class OrgDomainRoleClaim(JSONWizard):
84
+ class OrgDomainRoleClaim:
70
85
  status: str
71
86
  authorisation_domain: str
72
87
  role: str
@@ -97,16 +112,16 @@ class TokenSet:
97
112
 
98
113
 
99
114
  @dataclass
100
- class TokenInput(JSONWizard):
115
+ class TokenInput:
101
116
  access_token: str
102
- scope: str
103
117
  id_token: str
104
118
  token_type: str
105
119
  expires_in: Optional[int] = None
120
+ scope: Optional[str] = None
106
121
 
107
122
 
108
123
  @dataclass
109
- class MtlsEndpointAliases(JSONWizard):
124
+ class MtlsEndpointAliases:
110
125
  token_endpoint: Optional[str] = None
111
126
  pushed_authorization_request_endpoint: Optional[str] = None
112
127
  authorization_endpoint: Optional[str] = None
@@ -114,7 +129,7 @@ class MtlsEndpointAliases(JSONWizard):
114
129
 
115
130
 
116
131
  @dataclass
117
- class IssuerMetadata(JSONWizard):
132
+ class IssuerMetadata:
118
133
  issuer: str
119
134
  jwks_uri: str
120
135
  id_token_signing_alg_values_supported: List[str]
@@ -126,20 +141,20 @@ class IssuerMetadata(JSONWizard):
126
141
 
127
142
  def get_preferred_token_endpoint(self) -> str:
128
143
  return self.mtls_endpoint_aliases.token_endpoint \
129
- if self.mtls_endpoint_aliases.token_endpoint \
144
+ if self.mtls_endpoint_aliases and self.mtls_endpoint_aliases.token_endpoint \
130
145
  else self.token_endpoint
131
146
 
132
147
  def get_preferred_pushed_authorization_request_endpoint(self) -> str:
133
148
  return self.mtls_endpoint_aliases.pushed_authorization_request_endpoint \
134
- if self.mtls_endpoint_aliases.pushed_authorization_request_endpoint \
149
+ if self.mtls_endpoint_aliases and self.mtls_endpoint_aliases.pushed_authorization_request_endpoint \
135
150
  else self.pushed_authorization_request_endpoint
136
151
 
137
152
  def get_preferred_authorization_endpoint(self) -> str:
138
153
  return self.mtls_endpoint_aliases.authorization_endpoint \
139
- if self.mtls_endpoint_aliases.authorization_endpoint \
154
+ if self.mtls_endpoint_aliases and self.mtls_endpoint_aliases.authorization_endpoint \
140
155
  else self.authorization_endpoint
141
156
 
142
157
  def get_preferred_userinfo_endpoint(self) -> str:
143
158
  return self.mtls_endpoint_aliases.userinfo_endpoint \
144
- if self.mtls_endpoint_aliases.userinfo_endpoint\
159
+ if self.mtls_endpoint_aliases and self.mtls_endpoint_aliases.userinfo_endpoint \
145
160
  else self.userinfo_endpoint
@@ -14,7 +14,11 @@ class RelyingPartyClientSdk:
14
14
  self.config = config
15
15
 
16
16
  if config.custom_config.enable_auto_compliance_verification:
17
- logger.info(f"Auto Compliance Verification mode enabled, printing config information: {self.config}")
17
+ logger.info(f"Auto Compliance Verification mode enabled, printing config information: "
18
+ f"client_id={self.config.client_id}, "
19
+ f"signing_kid={self.config.signing_kid}, "
20
+ f"application_redirect_uri={self.config.application_redirect_uri}, "
21
+ f"registry_participants_uri={self.config.registry_participants_uri}")
18
22
 
19
23
  def get_participants(self) -> list[Participant]:
20
24
  return get_participants(self.config)