rp_python_sdk 0.3.0__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.
- rp_python_sdk/__init__.py +0 -0
- rp_python_sdk/endpoints/get_participants.py +98 -0
- rp_python_sdk/endpoints/pushed_authorisation_request.py +204 -0
- rp_python_sdk/endpoints/retrieve_tokens.py +254 -0
- rp_python_sdk/endpoints/user_info.py +44 -0
- rp_python_sdk/endpoints/util/fapi.py +30 -0
- rp_python_sdk/filters/participant_filters.py +99 -0
- rp_python_sdk/model.py +137 -0
- rp_python_sdk/relying_party_client_sdk.py +40 -0
- rp_python_sdk/relying_party_client_sdk_exception.py +2 -0
- rp_python_sdk/sdk_config.py +72 -0
- rp_python_sdk/setup_logger.py +12 -0
- rp_python_sdk-0.3.0.dist-info/LICENSE +21 -0
- rp_python_sdk-0.3.0.dist-info/METADATA +414 -0
- rp_python_sdk-0.3.0.dist-info/RECORD +16 -0
- rp_python_sdk-0.3.0.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from rp_python_sdk.filters.participant_filters import remove_out_of_date_certifications, \
|
|
6
|
+
remove_unofficial_certifications, filter_for_fallback_identity_service_providers, \
|
|
7
|
+
remove_participants_without_auth_servers, remove_fallback_identity_service_provider, \
|
|
8
|
+
filter_auth_servers_for_supported_claims, filter_for_required_certifications, remove_inactive_auth_servers
|
|
9
|
+
from rp_python_sdk.model import IssuerMetadata, AuthorisationServer
|
|
10
|
+
from rp_python_sdk.model import Participant
|
|
11
|
+
from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientSdkException
|
|
12
|
+
from rp_python_sdk.sdk_config import SdkConfig
|
|
13
|
+
from rp_python_sdk.setup_logger import logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_participants(config: SdkConfig) -> list[Participant]:
|
|
17
|
+
|
|
18
|
+
participants = _retrieve_full_participants_list(config)
|
|
19
|
+
|
|
20
|
+
filtered_participants = remove_fallback_identity_service_provider(participants)
|
|
21
|
+
|
|
22
|
+
if config.custom_config.include_uncertified_participants:
|
|
23
|
+
logger.info("Identity provider list has not been filtered as includeUncertifiedParticipants=true")
|
|
24
|
+
filtered_participants = remove_participants_without_auth_servers(filtered_participants)
|
|
25
|
+
return filtered_participants
|
|
26
|
+
|
|
27
|
+
filtered_participants = remove_out_of_date_certifications(filtered_participants, date.today())
|
|
28
|
+
filtered_participants = remove_unofficial_certifications(filtered_participants)
|
|
29
|
+
|
|
30
|
+
if len(config.custom_config.required_claims) != 0:
|
|
31
|
+
logger.debug(f"Identity provider list filtered for participants that support the "
|
|
32
|
+
f"following claims: {config.custom_config.required_claims}")
|
|
33
|
+
filtered_participants = filter_auth_servers_for_supported_claims(filtered_participants, config.custom_config.required_claims)
|
|
34
|
+
|
|
35
|
+
if len(config.custom_config.required_participant_certifications) != 0:
|
|
36
|
+
logger.debug(f"Identity provider list filtered for participants that support the "
|
|
37
|
+
f"following certifications: {config.custom_config.required_participant_certifications}")
|
|
38
|
+
filtered_participants = filter_for_required_certifications(filtered_participants, config.custom_config.required_participant_certifications)
|
|
39
|
+
|
|
40
|
+
filtered_participants = remove_inactive_auth_servers(filtered_participants)
|
|
41
|
+
filtered_participants = remove_participants_without_auth_servers(filtered_participants)
|
|
42
|
+
|
|
43
|
+
return filtered_participants
|
|
44
|
+
|
|
45
|
+
def retrieve_fallback_provider_participants(config: SdkConfig) -> list[Participant]:
|
|
46
|
+
participants = _retrieve_full_participants_list(config)
|
|
47
|
+
|
|
48
|
+
filtered_participants = remove_out_of_date_certifications(participants, date.today())
|
|
49
|
+
filtered_participants = remove_unofficial_certifications(filtered_participants)
|
|
50
|
+
|
|
51
|
+
filtered_participants = filter_for_fallback_identity_service_providers(filtered_participants)
|
|
52
|
+
filtered_participants = remove_participants_without_auth_servers(filtered_participants)
|
|
53
|
+
|
|
54
|
+
return filtered_participants
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _retrieve_full_participants_list(config: SdkConfig) -> list[Participant]:
|
|
58
|
+
logger.info(f"Retrieving all identity providers from {config.registry_participants_uri}")
|
|
59
|
+
|
|
60
|
+
response = requests.get(config.registry_participants_uri,
|
|
61
|
+
timeout=config.custom_config.timeout_in_seconds)
|
|
62
|
+
if response.status_code != 200:
|
|
63
|
+
raise RelyingPartyClientSdkException(
|
|
64
|
+
f"Call to identity provider endpoint failed with code: {response.status_code}")
|
|
65
|
+
|
|
66
|
+
participant_list: list[Participant] = Participant.from_json(response.text)
|
|
67
|
+
|
|
68
|
+
logger.info(f"Retrieved identity providers, num orgs found: {len(participant_list)}")
|
|
69
|
+
return participant_list
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_auth_server_details(config: SdkConfig, authorisation_server_id: str) -> AuthorisationServer:
|
|
73
|
+
result = None
|
|
74
|
+
for participant in get_participants(config):
|
|
75
|
+
for server in participant.authorisation_servers:
|
|
76
|
+
if str(server.authorisation_server_id) == authorisation_server_id:
|
|
77
|
+
result = server
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_issuer_metadata(authorisation_server: AuthorisationServer):
|
|
84
|
+
auth_server_issuer = authorisation_server.open_id_discovery_document
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
logger.info(f"Getting discovery document from {auth_server_issuer}")
|
|
88
|
+
response = requests.get(auth_server_issuer)
|
|
89
|
+
response.raise_for_status() # Raises an exception for 4XX or 5XX errors
|
|
90
|
+
|
|
91
|
+
return IssuerMetadata.from_json(response.text)
|
|
92
|
+
|
|
93
|
+
except requests.exceptions.HTTPError as http_err:
|
|
94
|
+
raise RelyingPartyClientSdkException(
|
|
95
|
+
f"Call to identity provider endpoint failed with code: {response.status_code}") from http_err
|
|
96
|
+
except requests.exceptions.RequestException as req_err:
|
|
97
|
+
raise RelyingPartyClientSdkException(
|
|
98
|
+
f"Failed to connect to authorisation service {auth_server_issuer}") from req_err
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from authlib.common.security import generate_token
|
|
8
|
+
from authlib.jose import JsonWebSignature
|
|
9
|
+
from authlib.oauth2.rfc7636 import create_s256_code_challenge
|
|
10
|
+
|
|
11
|
+
from rp_python_sdk.endpoints.get_participants import get_auth_server_details, \
|
|
12
|
+
get_issuer_metadata
|
|
13
|
+
from rp_python_sdk.endpoints.util.fapi import create_x_fapi_interaction_id, log_x_fapi_interaction_id_matches
|
|
14
|
+
from rp_python_sdk.model import PARResponse
|
|
15
|
+
from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientSdkException
|
|
16
|
+
from rp_python_sdk.sdk_config import SdkConfig
|
|
17
|
+
from rp_python_sdk.setup_logger import logger
|
|
18
|
+
|
|
19
|
+
default_claims = {"name", "given_name", "middle_name", "family_name", "phone_number", "email", "address", "birthdate",
|
|
20
|
+
"txn"}
|
|
21
|
+
|
|
22
|
+
extended_claims = {"over16", "over18", "over21", "over25", "over65", "beneficiary_account_au",
|
|
23
|
+
"beneficiary_account_au_payid", "beneficiary_account_international"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def send_pushed_authorisation_request(config: SdkConfig,
|
|
27
|
+
authorisation_server_id: str,
|
|
28
|
+
essential_claims: set[str],
|
|
29
|
+
voluntary_claims: set[str],
|
|
30
|
+
purpose: str) -> PARResponse:
|
|
31
|
+
if len(essential_claims) == 0:
|
|
32
|
+
essential_claims = default_claims
|
|
33
|
+
|
|
34
|
+
_check_valid_purpose(purpose)
|
|
35
|
+
|
|
36
|
+
checked_essential_claims = _ensure_mandatory_claims_present(essential_claims, voluntary_claims)
|
|
37
|
+
claim_request = _generate_claims_request(checked_essential_claims, voluntary_claims)
|
|
38
|
+
|
|
39
|
+
authorisation_server = get_auth_server_details(config, authorisation_server_id)
|
|
40
|
+
issuer_metadata = get_issuer_metadata(authorisation_server)
|
|
41
|
+
|
|
42
|
+
auth_server_url = issuer_metadata.issuer
|
|
43
|
+
x_fapi_interaction_id = create_x_fapi_interaction_id()
|
|
44
|
+
|
|
45
|
+
logger.info(
|
|
46
|
+
f"Sending PAR to auth server: {authorisation_server_id} - {authorisation_server.customer_friendly_name}, "
|
|
47
|
+
f"essential claims requested: {checked_essential_claims}, voluntary claims requested: {voluntary_claims}, "
|
|
48
|
+
f"x-fapi-interaction-id: {x_fapi_interaction_id}, purpose: {purpose}")
|
|
49
|
+
|
|
50
|
+
par_endpoint = issuer_metadata.get_preferred_pushed_authorization_request_endpoint()
|
|
51
|
+
auth_endpoint = issuer_metadata.get_preferred_authorization_endpoint()
|
|
52
|
+
redirect_uri = config.application_redirect_uri
|
|
53
|
+
client_id = config.client_id
|
|
54
|
+
|
|
55
|
+
current_time_in_seconds = int(time.time())
|
|
56
|
+
|
|
57
|
+
iat = current_time_in_seconds
|
|
58
|
+
exp = current_time_in_seconds + 300
|
|
59
|
+
|
|
60
|
+
state = generate_token()
|
|
61
|
+
nonce = generate_token()
|
|
62
|
+
scope = "openid"
|
|
63
|
+
jti = str(uuid.uuid4())
|
|
64
|
+
|
|
65
|
+
code_verifier = generate_token(48)
|
|
66
|
+
code_challenge = create_s256_code_challenge(code_verifier)
|
|
67
|
+
|
|
68
|
+
header = {'alg': 'PS256', 'kid': config.signing_kid}
|
|
69
|
+
request_payload = {
|
|
70
|
+
'iss': client_id,
|
|
71
|
+
'aud': auth_server_url,
|
|
72
|
+
'exp': exp,
|
|
73
|
+
'nbf': iat,
|
|
74
|
+
'response_type': 'code',
|
|
75
|
+
'code_challenge_method': 'S256',
|
|
76
|
+
'nonce': nonce,
|
|
77
|
+
'client_id': client_id,
|
|
78
|
+
'scope': scope,
|
|
79
|
+
'claims': claim_request,
|
|
80
|
+
'redirect_uri': redirect_uri,
|
|
81
|
+
'state': state,
|
|
82
|
+
'prompt': 'consent',
|
|
83
|
+
'code_challenge': code_challenge,
|
|
84
|
+
'purpose': purpose,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
jws = JsonWebSignature()
|
|
88
|
+
request_jwt = jws.serialize_compact(header, json.dumps(request_payload), config.signing_key)
|
|
89
|
+
|
|
90
|
+
client_assertion_payload = {
|
|
91
|
+
'sub': client_id,
|
|
92
|
+
'aud': auth_server_url,
|
|
93
|
+
'iss': client_id,
|
|
94
|
+
'exp': exp,
|
|
95
|
+
'jti': jti,
|
|
96
|
+
'iat': iat,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
client_assertion_jwt = jws.serialize_compact(header, json.dumps(client_assertion_payload),
|
|
100
|
+
config.signing_key)
|
|
101
|
+
|
|
102
|
+
# Data payload
|
|
103
|
+
data = {
|
|
104
|
+
"request": request_jwt.decode("utf-8"),
|
|
105
|
+
"client_assertion": client_assertion_jwt.decode("utf-8"),
|
|
106
|
+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Custom headers
|
|
110
|
+
headers = {
|
|
111
|
+
"accept": "application/json",
|
|
112
|
+
"x-fapi-interaction-id": x_fapi_interaction_id
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Making the POST request
|
|
116
|
+
response = requests.post(par_endpoint, data=data, headers=headers,
|
|
117
|
+
cert=(config.transport_pem, config.transport_key),
|
|
118
|
+
verify=config.ca_pem)
|
|
119
|
+
|
|
120
|
+
log_x_fapi_interaction_id_matches(authorisation_server, "send_pushed_authorisation_request", x_fapi_interaction_id, response)
|
|
121
|
+
|
|
122
|
+
request_uri = response.json()['request_uri']
|
|
123
|
+
logger.debug(f"PAR was sent, request_uri='{request_uri}'")
|
|
124
|
+
|
|
125
|
+
return PARResponse(_construct_auth_url(auth_endpoint, client_id, request_uri),
|
|
126
|
+
code_verifier, state, nonce, x_fapi_interaction_id)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_valid_purpose(purpose) -> None:
|
|
130
|
+
if len(purpose) < 3 or len(purpose) > 50:
|
|
131
|
+
raise RelyingPartyClientSdkException("Purpose must be between 3 and 50 characters")
|
|
132
|
+
|
|
133
|
+
illegal_chars = ["<", ">", "(", ")", "{", "}", "'", "\\"]
|
|
134
|
+
|
|
135
|
+
contains_illegal_char = any(char in purpose for char in illegal_chars)
|
|
136
|
+
if contains_illegal_char:
|
|
137
|
+
raise RelyingPartyClientSdkException(
|
|
138
|
+
f"Purpose cannot contain any of the following characters: {', '.join(illegal_chars)}, purpose supplied: [{purpose}]")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ensure_mandatory_claims_present(essential_claims: set[str], voluntary_claims: set[str]) -> set[str]:
|
|
142
|
+
if "txn" in essential_claims or "txn" in voluntary_claims:
|
|
143
|
+
return essential_claims
|
|
144
|
+
|
|
145
|
+
# Create a copy of the essential_claims to avoid modifying the original set
|
|
146
|
+
result_claims = essential_claims.copy()
|
|
147
|
+
# Add "txn" to the set
|
|
148
|
+
result_claims.add("txn")
|
|
149
|
+
return result_claims
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _generate_claims_request(essential_claims: set[str], voluntary_claims: set[str]):
|
|
153
|
+
# Filter out any claims that are in both essential and voluntary sets so essential takes precedence
|
|
154
|
+
deduplicated_voluntary_claims = voluntary_claims - essential_claims
|
|
155
|
+
|
|
156
|
+
# Create maps for essential and voluntary claims
|
|
157
|
+
essential = {"essential": True}
|
|
158
|
+
voluntary = {"essential": False}
|
|
159
|
+
|
|
160
|
+
# Partition claims into basic and extended types
|
|
161
|
+
partitioned_essential_claims = _partition_claims(essential_claims)
|
|
162
|
+
partitioned_voluntary_claims = _partition_claims(deduplicated_voluntary_claims)
|
|
163
|
+
|
|
164
|
+
# Map basic claims to their respective maps
|
|
165
|
+
id_token_claims = _map_claims(partitioned_essential_claims[False], essential)
|
|
166
|
+
id_token_claims.update(_map_claims(partitioned_voluntary_claims[False], voluntary))
|
|
167
|
+
|
|
168
|
+
# Map extended claims to their respective maps
|
|
169
|
+
extended_claims = _map_claims(partitioned_essential_claims[True], essential)
|
|
170
|
+
extended_claims.update(_map_claims(partitioned_voluntary_claims[True], voluntary))
|
|
171
|
+
|
|
172
|
+
# Add extended claims to idTokenClaims if any exist
|
|
173
|
+
if extended_claims:
|
|
174
|
+
id_token_claims["verified_claims"] = {
|
|
175
|
+
"verification": {"trust_framework": {"value": "au_connectid"}},
|
|
176
|
+
"claims": extended_claims
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {"id_token": id_token_claims}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _partition_claims(claims: set[str]):
|
|
183
|
+
"""
|
|
184
|
+
Partition claims into basic and extended types based on a criteria (here, whether they are in extended_claims).
|
|
185
|
+
"""
|
|
186
|
+
partitioned: dict[bool, set[str]] = {True: set(), False: set()}
|
|
187
|
+
for claim in claims:
|
|
188
|
+
partitioned[claim in extended_claims].add(claim)
|
|
189
|
+
return partitioned
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _map_claims(claims, claim_map):
|
|
193
|
+
"""
|
|
194
|
+
Map each claim to the provided claim_map.
|
|
195
|
+
"""
|
|
196
|
+
return {claim: claim_map for claim in claims}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _construct_auth_url(auth_endpoint: str, client_id: str, request_uri: str) -> str:
|
|
200
|
+
return (
|
|
201
|
+
f"{auth_endpoint}"
|
|
202
|
+
f"?client_id={quote(client_id)}"
|
|
203
|
+
f"&request_uri={quote(request_uri)}"
|
|
204
|
+
)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Tuple, Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from authlib.jose import JsonWebSignature
|
|
9
|
+
|
|
10
|
+
from rp_python_sdk.endpoints.get_participants import get_issuer_metadata, \
|
|
11
|
+
get_auth_server_details
|
|
12
|
+
from rp_python_sdk.endpoints.user_info import call_user_info
|
|
13
|
+
from rp_python_sdk.endpoints.util.fapi import create_x_fapi_interaction_id, log_x_fapi_interaction_id_matches
|
|
14
|
+
from rp_python_sdk.model import CallbackBody, TokenSet, TokenInput, IssuerMetadata
|
|
15
|
+
from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientSdkException
|
|
16
|
+
from rp_python_sdk.sdk_config import SdkConfig
|
|
17
|
+
from rp_python_sdk.setup_logger import logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def retrieve_tokens(config: SdkConfig, authorisation_server_id: str, callback_body: CallbackBody,
|
|
21
|
+
original_code_verifier: str,
|
|
22
|
+
original_state: str, nonce: str) -> TokenSet:
|
|
23
|
+
authorisation_server = get_auth_server_details(config, authorisation_server_id)
|
|
24
|
+
issuer_metadata = get_issuer_metadata(authorisation_server)
|
|
25
|
+
|
|
26
|
+
if issuer_metadata.issuer != callback_body.iss:
|
|
27
|
+
raise RelyingPartyClientSdkException(
|
|
28
|
+
f"Issuer returned from authorization endpoint ({callback_body.iss} does not match issuer defined in authorization server well-known ({issuer_metadata.issuer})")
|
|
29
|
+
|
|
30
|
+
x_fapi_interaction_id = create_x_fapi_interaction_id()
|
|
31
|
+
logger.info(
|
|
32
|
+
f"Retrieving token response from authorisation server: {authorisation_server_id} - {authorisation_server.customer_friendly_name}, x-fapi-interaction-id: {x_fapi_interaction_id}")
|
|
33
|
+
|
|
34
|
+
token_endpoint = issuer_metadata.get_preferred_token_endpoint()
|
|
35
|
+
|
|
36
|
+
if callback_body.state != original_state:
|
|
37
|
+
raise RelyingPartyClientSdkException(
|
|
38
|
+
f"Callback response state value does not match state, callback state: {callback_body.state}, state {original_state}, x-fapi-interaction-id: {x_fapi_interaction_id}")
|
|
39
|
+
|
|
40
|
+
if callback_body.iss != issuer_metadata.issuer:
|
|
41
|
+
raise RelyingPartyClientSdkException(
|
|
42
|
+
f"iss mismatch, expected {issuer_metadata.issuer} , got: {callback_body.iss}")
|
|
43
|
+
|
|
44
|
+
redirect_uri = config.application_redirect_uri
|
|
45
|
+
client_id = config.client_id
|
|
46
|
+
current_time_in_seconds = int(time.time())
|
|
47
|
+
iat = current_time_in_seconds
|
|
48
|
+
exp = current_time_in_seconds + 300
|
|
49
|
+
jti = str(uuid.uuid4())
|
|
50
|
+
|
|
51
|
+
client_assertion_payload = {
|
|
52
|
+
'sub': client_id,
|
|
53
|
+
'aud': token_endpoint,
|
|
54
|
+
'iss': client_id,
|
|
55
|
+
'exp': exp,
|
|
56
|
+
'jti': jti,
|
|
57
|
+
'iat': iat,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
jws = JsonWebSignature()
|
|
61
|
+
header = {'alg': 'PS256', 'kid': config.signing_kid}
|
|
62
|
+
|
|
63
|
+
client_assertion_jwt = jws.serialize_compact(header, json.dumps(client_assertion_payload),
|
|
64
|
+
config.signing_key)
|
|
65
|
+
|
|
66
|
+
# Data payload
|
|
67
|
+
data = {
|
|
68
|
+
"grant_type": "authorization_code",
|
|
69
|
+
"code": callback_body.code,
|
|
70
|
+
"redirect_uri": redirect_uri,
|
|
71
|
+
"client_assertion": client_assertion_jwt.decode("utf-8"),
|
|
72
|
+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
73
|
+
"code_verifier": original_code_verifier
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Custom headers
|
|
77
|
+
headers = {
|
|
78
|
+
"accept": "application/json",
|
|
79
|
+
"x-fapi-interaction-id": x_fapi_interaction_id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Making the POST request
|
|
83
|
+
response = requests.post(token_endpoint, data=data, headers=headers,
|
|
84
|
+
cert=(config.transport_pem, config.transport_key),
|
|
85
|
+
verify=config.ca_pem)
|
|
86
|
+
|
|
87
|
+
log_x_fapi_interaction_id_matches(authorisation_server, "retrieve_tokens", x_fapi_interaction_id, response)
|
|
88
|
+
|
|
89
|
+
token_input = TokenInput.from_json(response.text)
|
|
90
|
+
|
|
91
|
+
jwks_response_body = _get_jwks_json(issuer_metadata, x_fapi_interaction_id)
|
|
92
|
+
allowed_algs = issuer_metadata.id_token_signing_alg_values_supported
|
|
93
|
+
|
|
94
|
+
header, payload = _validate(token_input, jwks_response_body, allowed_algs, nonce, client_id, callback_body)
|
|
95
|
+
|
|
96
|
+
token_set = TokenSet(token_input, payload, x_fapi_interaction_id)
|
|
97
|
+
|
|
98
|
+
if config.custom_config.enable_auto_compliance_verification:
|
|
99
|
+
call_user_info(config, authorisation_server_id, token_set.token_input.access_token)
|
|
100
|
+
|
|
101
|
+
logger.info(
|
|
102
|
+
f"Token response successfully retrieved from authorisation server {authorisation_server_id}, x-fapi-interaction-id: {x_fapi_interaction_id}, txn: {token_set.claims['txn']}")
|
|
103
|
+
|
|
104
|
+
return token_set
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate(token_input: TokenInput, jwks_response_body: dict[str, Any], allowed_algs: list[str],
|
|
108
|
+
nonce: str, client_id: str,
|
|
109
|
+
callback_body: CallbackBody) -> Tuple[dict[str, Any], dict[str, Any]]:
|
|
110
|
+
"""
|
|
111
|
+
Decode and parse an ID token into JSON objects for header and payload.
|
|
112
|
+
|
|
113
|
+
:param token_input: A dictionary containing an 'idToken' key, which is a Base64 encoded string
|
|
114
|
+
:param jwks_response_body
|
|
115
|
+
:param allowed_algs
|
|
116
|
+
:param nonce
|
|
117
|
+
:param client_id
|
|
118
|
+
:param callback_body
|
|
119
|
+
:return: two dictionaries for header and payload
|
|
120
|
+
"""
|
|
121
|
+
id_token = token_input.id_token
|
|
122
|
+
header_base64 = id_token.split('.')[0]
|
|
123
|
+
header = _decode_base64(header_base64)
|
|
124
|
+
header_json = json.loads(header)
|
|
125
|
+
|
|
126
|
+
payload_base64 = id_token.split('.')[1]
|
|
127
|
+
payload = _decode_base64(payload_base64)
|
|
128
|
+
payload_json = json.loads(payload)
|
|
129
|
+
|
|
130
|
+
_validate_header(header_json, jwks_response_body, allowed_algs, id_token)
|
|
131
|
+
_validate_payload(payload_json, nonce, callback_body, client_id)
|
|
132
|
+
|
|
133
|
+
return header_json, payload_json
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _validate_payload(json_payload: dict[str, Any], nonce: str, callback_body: CallbackBody,
|
|
137
|
+
client_id: str) -> None:
|
|
138
|
+
if 'nonce' not in json_payload:
|
|
139
|
+
raise RelyingPartyClientSdkException('nonce claim missing from token response')
|
|
140
|
+
|
|
141
|
+
if json_payload['nonce'] != nonce:
|
|
142
|
+
raise RelyingPartyClientSdkException(
|
|
143
|
+
f"nonce returned in ID token: {json_payload['nonce']} does not match provided nonce: {nonce}")
|
|
144
|
+
|
|
145
|
+
current_time_stamp = int(time.time())
|
|
146
|
+
|
|
147
|
+
if 'iss' not in json_payload:
|
|
148
|
+
raise RelyingPartyClientSdkException('iss claim missing from token response')
|
|
149
|
+
|
|
150
|
+
if 'iat' not in json_payload:
|
|
151
|
+
raise RelyingPartyClientSdkException('iat claim missing from token response')
|
|
152
|
+
|
|
153
|
+
if 'aud' not in json_payload:
|
|
154
|
+
raise RelyingPartyClientSdkException('aud claim missing from token response')
|
|
155
|
+
|
|
156
|
+
if 'exp' not in json_payload:
|
|
157
|
+
raise RelyingPartyClientSdkException('exp claim missing from token response')
|
|
158
|
+
|
|
159
|
+
# Check if iat claim is older than 10 minutes ago
|
|
160
|
+
if json_payload['iat'] < current_time_stamp - 600:
|
|
161
|
+
raise RelyingPartyClientSdkException('iat claim in token response is too old')
|
|
162
|
+
|
|
163
|
+
# Check exp claim is older than current epoch (allowing for 5 minute skew)
|
|
164
|
+
if json_payload['exp'] < current_time_stamp - 300:
|
|
165
|
+
raise RelyingPartyClientSdkException('id token expired more than 5 minutes ago')
|
|
166
|
+
|
|
167
|
+
# Check the iss claim is valid
|
|
168
|
+
if json_payload['iss'] != callback_body.iss:
|
|
169
|
+
raise RelyingPartyClientSdkException(
|
|
170
|
+
f'iss claim in token response "{json_payload["iss"]}" does not match the expected value of "{callback_body.iss}"')
|
|
171
|
+
|
|
172
|
+
# Extract aud claim from either list or string
|
|
173
|
+
if isinstance(json_payload['aud'], list):
|
|
174
|
+
for aud_element in json_payload['aud']:
|
|
175
|
+
if aud_element != client_id:
|
|
176
|
+
raise RelyingPartyClientSdkException(
|
|
177
|
+
'one of the aud claim elements in token response does not match client ID')
|
|
178
|
+
else:
|
|
179
|
+
if json_payload['aud'] != client_id:
|
|
180
|
+
raise RelyingPartyClientSdkException('aud claim in token response does not match client ID')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _decode_base64(base64_str: str) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Decode a Base64 encoded string.
|
|
186
|
+
:param base64_str: Base64 encoded string
|
|
187
|
+
:return: decoded string
|
|
188
|
+
"""
|
|
189
|
+
decoded_bytes = base64.b64decode(base64_str + '==')
|
|
190
|
+
return decoded_bytes.decode('utf-8')
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _validate_header(json_header, jwks, allowed_algs, id_token: str) -> None:
|
|
194
|
+
if 'alg' not in json_header or json_header['alg'] == 'none':
|
|
195
|
+
raise RelyingPartyClientSdkException("no alg value in the token response jwt header")
|
|
196
|
+
|
|
197
|
+
if 'kid' not in json_header:
|
|
198
|
+
raise RelyingPartyClientSdkException("no kid value in the token response jwt header")
|
|
199
|
+
|
|
200
|
+
kid = json_header['kid']
|
|
201
|
+
jwk = None
|
|
202
|
+
|
|
203
|
+
for key_element in jwks.get('keys', []):
|
|
204
|
+
if _matching_kid(kid, key_element) and _none_or_matching_alg(json_header, key_element):
|
|
205
|
+
jwk = key_element
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
if jwk is None:
|
|
209
|
+
raise RelyingPartyClientSdkException(
|
|
210
|
+
f"kid provided does not match any key in the jwks for specified algorithm in token header: {json_header['alg']}")
|
|
211
|
+
|
|
212
|
+
found_alg = any(alg.lower() == json_header['alg'].lower() for alg in allowed_algs)
|
|
213
|
+
if not found_alg:
|
|
214
|
+
raise RelyingPartyClientSdkException(
|
|
215
|
+
"alg value in id token header is not one of the supported algorithms listed on the well-known")
|
|
216
|
+
|
|
217
|
+
# try:
|
|
218
|
+
# validate_signature(token_input.get('id_token'), jwk)
|
|
219
|
+
# except (JOSEError, ValueError) as e:
|
|
220
|
+
# raise RelyingPartyClientSdkException(f"exception while validation jwt signature: {e}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _validate_signature(self, id_token, jwk):
|
|
224
|
+
# Implement JWT signature validation based on the jwk provided and the library you're using
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _matching_kid(kid: str, key_element) -> bool:
|
|
229
|
+
# Implement this to match the 'kid' from the JWT with one from the JWKS
|
|
230
|
+
return key_element.get('kid') == kid
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _none_or_matching_alg(json_header, key_element) -> bool:
|
|
234
|
+
# Implement this to check if the 'alg' in JWT matches the one in the key element, if required
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _get_jwks_json(issuer_metadata: IssuerMetadata, x_fapi_interaction_id: str) -> Any:
|
|
239
|
+
headers = {
|
|
240
|
+
"Accept": "application/json",
|
|
241
|
+
"x-fapi-interaction-id": x_fapi_interaction_id
|
|
242
|
+
}
|
|
243
|
+
response = requests.get(issuer_metadata.jwks_uri, headers=headers)
|
|
244
|
+
response.raise_for_status()
|
|
245
|
+
|
|
246
|
+
if not _is_success(response):
|
|
247
|
+
raise RelyingPartyClientSdkException(
|
|
248
|
+
f"Response code for JWKS to {issuer_metadata.jwks_uri} not successful, was: {response.status_code}, x-fapi-interaction-id: {x_fapi_interaction_id}, response body: {response.text}")
|
|
249
|
+
|
|
250
|
+
return response.json()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_success(response: requests.Response):
|
|
254
|
+
return 200 <= response.status_code < 300
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from rp_python_sdk.endpoints.get_participants import get_issuer_metadata, \
|
|
6
|
+
get_auth_server_details
|
|
7
|
+
from rp_python_sdk.endpoints.util.fapi import create_x_fapi_interaction_id
|
|
8
|
+
from rp_python_sdk.relying_party_client_sdk_exception import RelyingPartyClientSdkException
|
|
9
|
+
from rp_python_sdk.sdk_config import SdkConfig
|
|
10
|
+
from rp_python_sdk.setup_logger import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def call_user_info(config: SdkConfig, authorisation_server_id: str, access_token: str) -> dict[str, Any]:
|
|
14
|
+
x_fapi_interaction_id = create_x_fapi_interaction_id()
|
|
15
|
+
logger.info(f"Making call to user info endpoint, x-fapi-interaction-id: {x_fapi_interaction_id}")
|
|
16
|
+
|
|
17
|
+
authorisation_server = get_auth_server_details(config, authorisation_server_id)
|
|
18
|
+
issuer_metadata = get_issuer_metadata(authorisation_server)
|
|
19
|
+
|
|
20
|
+
user_info_endpoint = issuer_metadata.get_preferred_userinfo_endpoint()
|
|
21
|
+
|
|
22
|
+
headers = {
|
|
23
|
+
"Authorization": f"Bearer {access_token}",
|
|
24
|
+
"x-fapi-interaction-id": x_fapi_interaction_id
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
response = requests.get(user_info_endpoint, headers=headers,
|
|
29
|
+
cert=(config.transport_pem, config.transport_key),
|
|
30
|
+
verify=config.ca_pem)
|
|
31
|
+
|
|
32
|
+
if not _is_success(response):
|
|
33
|
+
raise RelyingPartyClientSdkException(
|
|
34
|
+
f"Call to user info endpoint failed with code: {response.status_code}")
|
|
35
|
+
|
|
36
|
+
return response.json()
|
|
37
|
+
|
|
38
|
+
except requests.exceptions.RequestException as e:
|
|
39
|
+
raise RelyingPartyClientSdkException(
|
|
40
|
+
f"Failed to read body from response while connecting to {user_info_endpoint}") from e
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_success(response: requests.Response):
|
|
44
|
+
return 200 <= response.status_code < 300
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from requests.models import Response
|
|
4
|
+
|
|
5
|
+
from rp_python_sdk.model import AuthorisationServer
|
|
6
|
+
from rp_python_sdk.setup_logger import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_x_fapi_interaction_id():
|
|
10
|
+
return str(uuid.uuid4())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def log_x_fapi_interaction_id_matches(authorisation_server: AuthorisationServer, request_name: str,
|
|
14
|
+
x_fapi_interaction_id: str, response: Response):
|
|
15
|
+
fapi_header = response.headers.get('x-fapi-interaction-id')
|
|
16
|
+
if not fapi_header:
|
|
17
|
+
logger.warning(
|
|
18
|
+
f"No x-fapi-interaction-id header returned from auth server {authorisation_server.authorisation_server_id} "
|
|
19
|
+
f"on request {request_name}, expected {x_fapi_interaction_id}")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
if x_fapi_interaction_id == fapi_header:
|
|
23
|
+
logger.debug(
|
|
24
|
+
f"x-fapi-interaction-id correctly returned from auth server {authorisation_server.authorisation_server_id} "
|
|
25
|
+
f"on {request_name} matched expected {x_fapi_interaction_id}")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
logger.warning(
|
|
29
|
+
f"x-fapi-interaction-id returned from auth server {authorisation_server.authorisation_server_id} "
|
|
30
|
+
f"on {request_name} did not match. sent {x_fapi_interaction_id}, returned {fapi_header}")
|