Skip to content

Commit 70aeeae

Browse files
committed
feat: add reply validators and hosted fields oauth service
1 parent 3aeab56 commit 70aeeae

8 files changed

Lines changed: 659 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Hosted Fields token service for Buckaroo credit card tokenization.
2+
3+
The Buckaroo Hosted Fields iframe tokenizes card data client-side, keeping
4+
PAN/CVV out of the merchant server. The iframe needs a short-lived JWT,
5+
minted via the OAuth2 ``client_credentials`` flow against
6+
``auth.buckaroo.io`` using the merchant's Hosted Fields client_id /
7+
client_secret pair (distinct from the Buckaroo store key / secret key
8+
used for HMAC-signed API calls).
9+
10+
The PHP SDK does not include this service; OAuth is out of scope for the
11+
core transaction API. This module exists only to consolidate the Hosted
12+
Fields token exchange in one place.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import base64
18+
import json
19+
from typing import Any, Dict, Optional
20+
from urllib.parse import urlencode
21+
22+
from ..exceptions._buckaroo_error import BuckarooError
23+
from ..http.strategies import HttpStrategy, HttpStrategyFactory
24+
25+
26+
class HostedFieldsService:
27+
"""Mint OAuth access tokens for the Buckaroo Hosted Fields iframe."""
28+
29+
OAUTH_TOKEN_URL = "https://auth.buckaroo.io/oauth/token"
30+
GRANT_TYPE = "client_credentials"
31+
DEFAULT_SCOPE = "hostedfields:save"
32+
33+
def __init__(
34+
self,
35+
client_id: str,
36+
client_secret: str,
37+
http_strategy: Optional[HttpStrategy] = None,
38+
timeout: int = 10,
39+
) -> None:
40+
if client_id is None or not client_id.strip():
41+
raise ValueError("Client ID must be provided")
42+
if client_secret is None or not client_secret.strip():
43+
raise ValueError("Client secret must be provided")
44+
45+
self._client_id = client_id.strip()
46+
self._client_secret = client_secret.strip()
47+
self._timeout = timeout
48+
self._injected_strategy = http_strategy
49+
self._strategy: Optional[HttpStrategy] = None
50+
51+
def _get_strategy(self) -> HttpStrategy:
52+
if self._injected_strategy is not None:
53+
return self._injected_strategy
54+
if self._strategy is None:
55+
self._strategy = HttpStrategyFactory.create_strategy()
56+
self._strategy.configure()
57+
return self._strategy
58+
59+
def get_token(self, scope: Optional[str] = None) -> Dict[str, Any]:
60+
"""Mint a Hosted Fields access token. Returns the decoded JSON body."""
61+
scope = scope or self.DEFAULT_SCOPE
62+
credentials = base64.b64encode(
63+
f"{self._client_id}:{self._client_secret}".encode("utf-8")
64+
).decode("ascii")
65+
body = urlencode({"scope": scope, "grant_type": self.GRANT_TYPE})
66+
67+
try:
68+
response = self._get_strategy().request(
69+
method="POST",
70+
url=self.OAUTH_TOKEN_URL,
71+
headers={
72+
"Authorization": f"Basic {credentials}",
73+
"Content-Type": "application/x-www-form-urlencoded",
74+
},
75+
data=body,
76+
timeout=self._timeout,
77+
)
78+
except Exception as exc:
79+
if isinstance(exc, BuckarooError):
80+
raise
81+
raise BuckarooError(f"Hosted Fields token request failed: {exc}") from exc
82+
83+
if not response.success:
84+
error = self._extract_error(response.text)
85+
raise BuckarooError(
86+
f"Hosted Fields token request failed (status {response.status_code}): {error}"
87+
)
88+
89+
text = response.text or ""
90+
if not text.strip():
91+
raise BuckarooError("Hosted Fields token response was empty")
92+
try:
93+
return json.loads(text)
94+
except json.JSONDecodeError as exc:
95+
raise BuckarooError(
96+
f"Failed to parse Hosted Fields token response JSON: {exc}"
97+
) from exc
98+
99+
@staticmethod
100+
def _extract_error(text: Optional[str]) -> str:
101+
"""Pull RFC6749 error fields from the response without echoing the full body."""
102+
if not text:
103+
return "no body"
104+
try:
105+
data = json.loads(text)
106+
except json.JSONDecodeError:
107+
return "non-JSON body"
108+
if not isinstance(data, dict):
109+
return "unexpected body shape"
110+
code = data.get("error") or "unknown_error"
111+
description = data.get("error_description")
112+
return f"{code}: {description}" if description else code
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Reply (push notification) verification handlers.
2+
3+
Mirrors the PHP SDK ``Handlers/Reply/`` folder. Pick the strategy that
4+
matches the wire format of the incoming Buckaroo push:
5+
6+
* :class:`buckaroo.services.reply.http_post.HttpPost` — form-encoded pushes
7+
(SHA-1 over ``brq_signature``).
8+
* :class:`buckaroo.services.reply.json_reply.Json` — JSON pushes (HMAC-SHA256
9+
over the ``Authorization`` header).
10+
"""
11+
12+
from .http_post import HttpPost
13+
from .json_reply import Json
14+
15+
__all__ = ["HttpPost", "Json"]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Form-encoded push verifier (SHA-1 over ``brq_signature``).
2+
3+
Mirrors PHP ``Handlers/Reply/HttpPost.php``. Buckaroo signs form-style push
4+
notifications with a SHA-1 digest over the sorted ``brq_*`` / ``add_*`` /
5+
``cust_*`` parameters concatenated with the merchant secret key, delivered
6+
in the ``brq_signature`` field of the form body.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import hmac
12+
from hashlib import sha1
13+
from typing import Mapping
14+
from urllib.parse import unquote_plus
15+
16+
17+
class HttpPost:
18+
"""Verify SHA-1 signatures on form-style Buckaroo pushes."""
19+
20+
SIGNATURE_FIELD = "brq_signature"
21+
INCLUDE_PREFIXES = ("add_", "brq_", "cust_")
22+
23+
def __init__(self, secret_key: str) -> None:
24+
if secret_key is None or not secret_key.strip():
25+
raise ValueError("Secret key must be provided")
26+
self.secret_key = secret_key.strip()
27+
28+
def validate(self, params: Mapping[str, object]) -> bool:
29+
"""Return True if ``params['brq_signature']`` matches the computed signature."""
30+
provided = next(
31+
(
32+
v for k, v in params.items()
33+
if k.lower() == self.SIGNATURE_FIELD
34+
),
35+
None,
36+
)
37+
if not provided or not isinstance(provided, str):
38+
return False
39+
expected = self.compute_signature(params)
40+
return hmac.compare_digest(provided, expected)
41+
42+
def compute_signature(self, params: Mapping[str, object]) -> str:
43+
"""Compute the expected SHA-1 signature for a form-style push."""
44+
decoded = [
45+
(k, unquote_plus(v) if isinstance(v, str) else v)
46+
for k, v in params.items()
47+
if k.lower() != self.SIGNATURE_FIELD
48+
]
49+
filtered = [
50+
(k, v) for k, v in decoded
51+
if any(k.lower().startswith(p) for p in self.INCLUDE_PREFIXES)
52+
]
53+
sorted_items = sorted(filtered, key=lambda pair: pair[0].lower())
54+
sign_string = "".join(
55+
f"{k}={v if v is not None else ''}" for k, v in sorted_items
56+
)
57+
sign_string += self.secret_key
58+
return sha1(sign_string.encode("utf-8")).hexdigest()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""JSON push verifier (HMAC-SHA256 over the ``Authorization`` header).
2+
3+
Mirrors PHP ``Handlers/Reply/Json.php`` (which delegates to
4+
``Handlers/HMAC/Validator.php``). Buckaroo signs JSON push notifications
5+
with an HMAC-SHA256 hash carried in the
6+
``Authorization: hmac key:hash:nonce:time`` header. Verification recomputes
7+
the hash from the URL, method, body, and merchant credentials, then
8+
compares constant-time.
9+
10+
Module file is ``json_reply.py`` (not ``json.py``) to avoid shadowing the
11+
stdlib :mod:`json`. Class name is :class:`Json` for parity with the PHP SDK.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import base64
17+
import hashlib
18+
import hmac as _hmac
19+
from typing import Optional, Union
20+
from urllib.parse import quote
21+
22+
23+
class Json:
24+
"""Verify HMAC-SHA256 signatures on JSON-style Buckaroo pushes."""
25+
26+
def __init__(self, store_key: str, secret_key: str) -> None:
27+
if store_key is None or not store_key.strip():
28+
raise ValueError("Store key must be provided")
29+
if secret_key is None or not secret_key.strip():
30+
raise ValueError("Secret key must be provided")
31+
self.store_key = store_key.strip()
32+
self.secret_key = secret_key.strip()
33+
34+
def validate(
35+
self,
36+
authorization: Optional[str],
37+
uri: str,
38+
method: str,
39+
body: Union[str, bytes, None] = "",
40+
) -> bool:
41+
"""Return True if ``authorization`` is a valid HMAC for the request."""
42+
if not authorization:
43+
return False
44+
45+
parts = authorization.split(":")
46+
if len(parts) != 4:
47+
return False
48+
provided_hash = parts[1]
49+
nonce = parts[2]
50+
timestamp = parts[3]
51+
52+
content_b64 = self._md5_b64(body)
53+
encoded_url = self._encode_url(uri)
54+
signing_string = (
55+
f"{self.store_key}{method}{encoded_url}{timestamp}{nonce}{content_b64}"
56+
)
57+
expected = base64.b64encode(
58+
_hmac.new(
59+
self.secret_key.encode("utf-8"),
60+
signing_string.encode("utf-8"),
61+
hashlib.sha256,
62+
).digest()
63+
).decode("ascii")
64+
65+
return _hmac.compare_digest(provided_hash, expected)
66+
67+
@staticmethod
68+
def _md5_b64(body: Union[str, bytes, None]) -> str:
69+
if not body:
70+
return ""
71+
if isinstance(body, str):
72+
body = body.encode("utf-8")
73+
return base64.b64encode(hashlib.md5(body).digest()).decode("ascii")
74+
75+
@staticmethod
76+
def _encode_url(url: str) -> str:
77+
if url.startswith("https://"):
78+
url = url[8:]
79+
elif url.startswith("http://"):
80+
url = url[7:]
81+
return quote(url, safe="").lower()

tests/unit/services/reply/__init__.py

Whitespace-only changes.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Tests for :class:`buckaroo.services.reply.http_post.HttpPost`."""
2+
3+
from __future__ import annotations
4+
5+
from hashlib import sha1
6+
7+
import pytest
8+
9+
from buckaroo.services.reply.http_post import HttpPost
10+
11+
12+
SECRET = "secretkey"
13+
14+
15+
def _sign(params: dict, secret: str = SECRET) -> str:
16+
"""Reference SHA-1 signature per Buckaroo's documented algorithm."""
17+
items = [(k, v) for k, v in params.items() if k.lower() != "brq_signature"]
18+
filtered = [
19+
(k, v) for k, v in items
20+
if any(k.lower().startswith(p) for p in ("add_", "brq_", "cust_"))
21+
]
22+
sorted_items = sorted(filtered, key=lambda pair: pair[0].lower())
23+
sign_string = "".join(f"{k}={v if v is not None else ''}" for k, v in sorted_items)
24+
sign_string += secret
25+
return sha1(sign_string.encode("utf-8")).hexdigest()
26+
27+
28+
class TestComputeSignature:
29+
def test_sorts_keys_case_insensitively(self):
30+
h = HttpPost(SECRET)
31+
params = {"brq_b": "2", "BRQ_a": "1"}
32+
assert h.compute_signature(params) == sha1(
33+
f"BRQ_a=1brq_b=2{SECRET}".encode("utf-8")
34+
).hexdigest()
35+
36+
def test_excludes_brq_signature_field(self):
37+
h = HttpPost(SECRET)
38+
params = {"brq_x": "1", "brq_signature": "deadbeef"}
39+
expected = sha1(f"brq_x=1{SECRET}".encode("utf-8")).hexdigest()
40+
assert h.compute_signature(params) == expected
41+
42+
def test_filters_to_brq_add_cust_prefixes_only(self):
43+
h = HttpPost(SECRET)
44+
params = {"brq_a": "1", "add_b": "2", "cust_c": "3", "other_d": "4"}
45+
expected = sha1(f"add_b=2brq_a=1cust_c=3{SECRET}".encode("utf-8")).hexdigest()
46+
assert h.compute_signature(params) == expected
47+
48+
def test_url_decodes_values(self):
49+
h = HttpPost(SECRET)
50+
params = {"brq_x": "hello+world"}
51+
expected = sha1(f"brq_x=hello world{SECRET}".encode("utf-8")).hexdigest()
52+
assert h.compute_signature(params) == expected
53+
54+
def test_handles_empty_value(self):
55+
h = HttpPost(SECRET)
56+
params = {"brq_x": ""}
57+
expected = sha1(f"brq_x={SECRET}".encode("utf-8")).hexdigest()
58+
assert h.compute_signature(params) == expected
59+
60+
61+
class TestValidate:
62+
def test_returns_true_for_valid_signature(self):
63+
h = HttpPost(SECRET)
64+
params = {"brq_amount": "10.00", "brq_invoicenumber": "INV-1"}
65+
params["brq_signature"] = _sign(params)
66+
assert h.validate(params) is True
67+
68+
def test_returns_false_for_invalid_signature(self):
69+
h = HttpPost(SECRET)
70+
params = {"brq_amount": "10.00", "brq_signature": "not-a-real-sig"}
71+
assert h.validate(params) is False
72+
73+
def test_returns_false_when_signature_missing(self):
74+
h = HttpPost(SECRET)
75+
assert h.validate({"brq_amount": "10.00"}) is False
76+
77+
def test_returns_false_when_signature_empty(self):
78+
h = HttpPost(SECRET)
79+
assert h.validate({"brq_amount": "10.00", "brq_signature": ""}) is False
80+
81+
def test_signature_field_lookup_is_case_insensitive(self):
82+
h = HttpPost(SECRET)
83+
params = {"brq_amount": "10.00"}
84+
params["BRQ_SIGNATURE"] = _sign(params)
85+
assert h.validate(params) is True
86+
87+
def test_uses_constant_time_compare(self):
88+
h = HttpPost(SECRET)
89+
params = {"brq_amount": "10.00"}
90+
valid = _sign(params)
91+
params["brq_signature"] = "0" * len(valid)
92+
assert h.validate(params) is False
93+
94+
95+
class TestConstruction:
96+
def test_rejects_empty_secret_key(self):
97+
with pytest.raises(ValueError):
98+
HttpPost("")
99+
100+
def test_rejects_whitespace_only_secret_key(self):
101+
with pytest.raises(ValueError):
102+
HttpPost(" ")
103+
104+
def test_strips_secret_key(self):
105+
h = HttpPost(" sec ")
106+
assert h.secret_key == "sec"

0 commit comments

Comments
 (0)