Skip to content

Commit aa63ecd

Browse files
Merge pull request #138 from bunq/feature/sdk_python#136_complete_oauth_psd2_implementation
sdk_python#136 complete Oauth/PSD2 implementation
2 parents 6efe30d + 42d9e8b commit aa63ecd

File tree

7 files changed

+317
-0
lines changed

7 files changed

+317
-0
lines changed

bunq/sdk/http/anonymous_api_client.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from typing import Dict
2+
3+
import requests
4+
5+
from bunq.sdk.context.api_context import ApiContext
6+
from bunq.sdk.http.api_client import ApiClient
7+
from bunq.sdk.http.bunq_response_raw import BunqResponseRaw
8+
from bunq.sdk.security import security
9+
10+
11+
class AnonymousApiClient(ApiClient):
12+
13+
def __init__(self, api_context: ApiContext) -> None:
14+
super().__init__(api_context)
15+
16+
def post(self,
17+
uri_relative: str,
18+
request_bytes: bytes,
19+
custom_headers: Dict[str, str]) -> BunqResponseRaw:
20+
return self._request(
21+
self.METHOD_POST,
22+
uri_relative,
23+
request_bytes,
24+
{},
25+
custom_headers
26+
)
27+
28+
def _request(self,
29+
method: str,
30+
uri_relative: str,
31+
request_bytes: bytes,
32+
params: Dict[str, str],
33+
custom_headers: Dict[str, str]) -> BunqResponseRaw:
34+
from bunq.sdk.context.bunq_context import BunqContext
35+
36+
uri_relative_with_params = self._append_params_to_uri(uri_relative, params)
37+
if uri_relative not in self._URIS_NOT_REQUIRING_ACTIVE_SESSION:
38+
if self._api_context.ensure_session_active():
39+
BunqContext.update_api_context(self._api_context)
40+
41+
all_headers = self._get_all_headers(
42+
request_bytes,
43+
custom_headers
44+
)
45+
46+
response = requests.request(
47+
method,
48+
uri_relative_with_params,
49+
data=request_bytes,
50+
headers=all_headers,
51+
proxies={self.FIELD_PROXY_HTTPS: self._api_context.proxy_url},
52+
)
53+
54+
self._assert_response_success(response)
55+
56+
if self._api_context.installation_context is not None:
57+
security.validate_response(
58+
self._api_context.installation_context.public_key_server,
59+
response.status_code,
60+
response.content,
61+
response.headers
62+
)
63+
64+
return BunqResponseRaw(response.content, response.headers)

bunq/sdk/http/http_util.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import Dict
2+
3+
4+
class HttpUtil:
5+
QUERY_FORMAT = '{}={}'
6+
QUERY_DELIMITER = '&'
7+
8+
@classmethod
9+
def create_query_string(cls, all_parameter: Dict[str, str]):
10+
encoded_parameters = []
11+
12+
for parameter, value in all_parameter.items():
13+
encoded_parameters.append(cls.QUERY_FORMAT.format(parameter, value))
14+
15+
return cls.QUERY_DELIMITER.join(encoded_parameters)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional, Type
4+
5+
from bunq import ApiEnvironmentType
6+
from bunq.sdk.context.bunq_context import BunqContext
7+
from bunq.sdk.exception.bunq_exception import BunqException
8+
from bunq.sdk.http.anonymous_api_client import AnonymousApiClient
9+
from bunq.sdk.http.bunq_response import BunqResponse
10+
from bunq.sdk.http.bunq_response_raw import BunqResponseRaw
11+
from bunq.sdk.http.http_util import HttpUtil
12+
from bunq.sdk.json import converter
13+
from bunq.sdk.model.core.bunq_model import BunqModel
14+
from bunq.sdk.model.core.oauth_grant_type import OauthGrantType
15+
from bunq.sdk.model.generated.endpoint import OauthClient
16+
from bunq.sdk.util.type_alias import T
17+
18+
19+
class OauthAccessToken(BunqModel):
20+
# Field constants.
21+
FIELD_GRANT_TYPE = "grant_type"
22+
FIELD_CODE = "code"
23+
FIELD_REDIRECT_URI = "redirect_uri"
24+
FIELD_CLIENT_ID = "client_id"
25+
FIELD_CLIENT_SECRET = "client_secret"
26+
27+
# Token constants.
28+
TOKEN_URI_FORMAT_SANDBOX = "https://api-oauth.sandbox.bunq.com/v1/token?%s"
29+
TOKEN_URI_FORMAT_PRODUCTION = "https://api.oauth.bunq.com/v1/token?%s"
30+
31+
# Error constants.
32+
ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED = "You are trying to use an unsupported environment type."
33+
34+
def __init__(self, token: str, token_type: str, state: str = None) -> None:
35+
self._token = token
36+
self._token_type = token_type
37+
self._state = state
38+
39+
@property
40+
def token(self) -> str:
41+
return self._token
42+
43+
@property
44+
def token_type(self) -> str:
45+
return self._token_type
46+
47+
@property
48+
def state(self) -> Optional[str]:
49+
return self._state
50+
51+
@classmethod
52+
def create(cls,
53+
grant_type: OauthGrantType,
54+
oauth_code: str,
55+
redirect_uri: str,
56+
client: OauthClient) -> OauthAccessToken:
57+
api_client = AnonymousApiClient(BunqContext.api_context())
58+
response_raw = api_client.post(
59+
cls.create_token_uri(grant_type.value, oauth_code, redirect_uri, client),
60+
bytearray(),
61+
{}
62+
)
63+
64+
return cls.from_json(OauthAccessToken, response_raw).value
65+
66+
@classmethod
67+
def create_token_uri(cls, grant_type: str, auth_code: str, redirect_uri: str, client: OauthClient) -> str:
68+
all_token_parameter = {
69+
cls.FIELD_GRANT_TYPE: grant_type,
70+
cls.FIELD_CODE: auth_code,
71+
cls.FIELD_REDIRECT_URI: redirect_uri,
72+
cls.FIELD_CLIENT_ID: client.id_,
73+
cls.FIELD_CLIENT_SECRET: client.secret,
74+
}
75+
76+
return cls.determine_auth_uri_format().format(HttpUtil.create_query_string(all_token_parameter))
77+
78+
def is_all_field_none(self) -> bool:
79+
if self._token is not None:
80+
return False
81+
elif self._token_type is not None:
82+
return False
83+
elif self._state is not None:
84+
return False
85+
86+
return True
87+
88+
@classmethod
89+
def from_json(cls, class_of_object: Type[T], response_raw: BunqResponseRaw):
90+
response_item_object = converter.deserialize(class_of_object, response_raw)
91+
response_value = converter.json_to_class(class_of_object, response_item_object)
92+
93+
return BunqResponse(response_value, response_raw.headers)
94+
95+
@classmethod
96+
def determine_auth_uri_format(cls) -> str:
97+
environment_type = BunqContext.api_context().environment_type
98+
99+
if ApiEnvironmentType.PRODUCTION == environment_type:
100+
return cls.TOKEN_URI_FORMAT_PRODUCTION
101+
102+
if ApiEnvironmentType.SANDBOX == environment_type:
103+
return cls.TOKEN_URI_FORMAT_SANDBOX
104+
105+
raise BunqException(cls.ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
from bunq import ApiEnvironmentType
4+
from bunq.sdk.context.bunq_context import BunqContext
5+
from bunq.sdk.exception.bunq_exception import BunqException
6+
from bunq.sdk.http.http_util import HttpUtil
7+
from bunq.sdk.model.core.bunq_model import BunqModel
8+
from bunq.sdk.model.core.oauth_response_type import OauthResponseType
9+
from bunq.sdk.model.generated.endpoint import OauthClient
10+
11+
12+
class OauthAuthorizationUri(BunqModel):
13+
# Auth constants.
14+
AUTH_URI_FORMAT_SANDBOX = "https://oauth.sandbox.bunq.com/auth?{}"
15+
AUTH_URI_FORMAT_PRODUCTION = "https://oauth.bunq.com/auth?{}"
16+
17+
# Field constants
18+
FIELD_RESPONSE_TYPE = "response_type"
19+
FIELD_REDIRECT_URI = "redirect_uri"
20+
FIELD_STATE = "state"
21+
FIELD_CLIENT_ID = "client_id"
22+
23+
# Error constants.
24+
ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED = "You are trying to use an unsupported environment type."
25+
26+
def __init__(self, authorization_uri: str) -> None:
27+
self._authorization_uri = authorization_uri
28+
29+
@property
30+
def authorization_uri(self) -> str:
31+
return self._authorization_uri
32+
33+
@classmethod
34+
def create(cls,
35+
response_type: OauthResponseType,
36+
redirect_uri: str,
37+
client: OauthClient,
38+
state: str = None) -> OauthAuthorizationUri:
39+
all_request_parameter = {
40+
cls.FIELD_REDIRECT_URI: redirect_uri,
41+
cls.FIELD_RESPONSE_TYPE: response_type.name.lower()
42+
}
43+
44+
if client.client_id is not None:
45+
all_request_parameter[cls.FIELD_CLIENT_ID] = client.client_id
46+
47+
if state is not None:
48+
all_request_parameter[cls.FIELD_STATE] = state
49+
50+
return OauthAuthorizationUri(
51+
cls.determine_auth_uri_format().format(HttpUtil.create_query_string(all_request_parameter))
52+
)
53+
54+
def get_authorization_uri(self) -> str:
55+
return self._authorization_uri
56+
57+
def is_all_field_none(self) -> bool:
58+
if self._authorization_uri is None:
59+
return True
60+
else:
61+
return False
62+
63+
@classmethod
64+
def determine_auth_uri_format(cls) -> str:
65+
environment_type = BunqContext.api_context().environment_type
66+
67+
if ApiEnvironmentType.PRODUCTION == environment_type:
68+
return cls.AUTH_URI_FORMAT_PRODUCTION
69+
70+
if ApiEnvironmentType.SANDBOX == environment_type:
71+
return cls.AUTH_URI_FORMAT_SANDBOX
72+
73+
raise BunqException(cls.ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import aenum
2+
3+
4+
class OauthGrantType(aenum.AutoNumberEnum):
5+
"""
6+
:type AUTHORIZATION_CODE: str
7+
:type grant_type: str
8+
"""
9+
10+
AUTHORIZATION_CODE = 'authorization_code'
11+
12+
def __init__(self, grant_type: str) -> None:
13+
self._grant_type = grant_type
14+
15+
@property
16+
def grant_type(self) -> str:
17+
return self.grant_type
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import aenum
2+
3+
4+
class OauthResponseType(aenum.AutoNumberEnum):
5+
"""
6+
:type CODE: str
7+
:type response_type: str
8+
"""
9+
10+
CODE = 'code'
11+
12+
def __init__(self, response_type: str) -> None:
13+
self._response_type = response_type
14+
15+
@property
16+
def response_type(self) -> str:
17+
return self._response_type
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from bunq.sdk.context.bunq_context import BunqContext
2+
from bunq.sdk.model.core.oauth_authorization_uri import OauthAuthorizationUri
3+
from bunq.sdk.model.core.oauth_response_type import OauthResponseType
4+
from bunq.sdk.model.generated.endpoint import OauthClient
5+
from tests.bunq_test import BunqSdkTestCase
6+
7+
8+
class TestOauthAuthorizationUri(BunqSdkTestCase):
9+
_TEST_EXPECT_URI = 'https://oauth.sandbox.bunq.com/auth?redirect_uri=redirecturi&response_type=code&state=state'
10+
_TEST_REDIRECT_URI = 'redirecturi'
11+
_TEST_STATUS = 'status'
12+
_TEST_STATE = 'state'
13+
14+
@classmethod
15+
def setUpClass(cls) -> None:
16+
BunqContext.load_api_context(cls._get_api_context())
17+
18+
def test_oauth_authorization_uri_create(self) -> None:
19+
uri = OauthAuthorizationUri.create(
20+
OauthResponseType(OauthResponseType.CODE),
21+
self._TEST_REDIRECT_URI,
22+
OauthClient(self._TEST_STATUS),
23+
self._TEST_STATE
24+
).get_authorization_uri()
25+
26+
self.assertEqual(self._TEST_EXPECT_URI, uri)

0 commit comments

Comments
 (0)