valgix-humanpass 0.1.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.
- valgix_humanpass-0.1.0/.gitignore +7 -0
- valgix_humanpass-0.1.0/PKG-INFO +47 -0
- valgix_humanpass-0.1.0/README.md +26 -0
- valgix_humanpass-0.1.0/pyproject.toml +41 -0
- valgix_humanpass-0.1.0/src/humanpass/__init__.py +21 -0
- valgix_humanpass-0.1.0/src/humanpass/client.py +222 -0
- valgix_humanpass-0.1.0/src/humanpass/errors.py +28 -0
- valgix_humanpass-0.1.0/src/humanpass/models.py +52 -0
- valgix_humanpass-0.1.0/src/humanpass/py.typed +1 -0
- valgix_humanpass-0.1.0/tests/test_client.py +80 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: valgix-humanpass
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python server SDK for Valgix HumanPass.
|
|
5
|
+
Project-URL: Documentation, https://docs.valgix.com/humanpass/
|
|
6
|
+
Project-URL: Repository, https://github.com/valgixcenter/humanpass-python
|
|
7
|
+
Project-URL: Issues, https://github.com/valgixcenter/humanpass-python/issues
|
|
8
|
+
Author: Valgix
|
|
9
|
+
Keywords: captcha,humanpass,security,valgix
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# HumanPass for Python
|
|
23
|
+
|
|
24
|
+
Official Python server SDK for [Valgix HumanPass](https://docs.valgix.com/humanpass/).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install valgix-humanpass
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
from humanpass import HumanPass
|
|
33
|
+
|
|
34
|
+
humanpass = HumanPass(secret=os.environ["HUMANPASS_SECRET_KEY"])
|
|
35
|
+
|
|
36
|
+
result = humanpass.verify(
|
|
37
|
+
response=request.form["humanpass-response"],
|
|
38
|
+
expected_action="signup",
|
|
39
|
+
expected_hostname="example.com",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if not result.success:
|
|
43
|
+
# Reject the protected operation.
|
|
44
|
+
pass
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Keep the secret key on your server. Browser applications use the HumanPass widget and a public site key instead.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# HumanPass for Python
|
|
2
|
+
|
|
3
|
+
Official Python server SDK for [Valgix HumanPass](https://docs.valgix.com/humanpass/).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install valgix-humanpass
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import os
|
|
11
|
+
from humanpass import HumanPass
|
|
12
|
+
|
|
13
|
+
humanpass = HumanPass(secret=os.environ["HUMANPASS_SECRET_KEY"])
|
|
14
|
+
|
|
15
|
+
result = humanpass.verify(
|
|
16
|
+
response=request.form["humanpass-response"],
|
|
17
|
+
expected_action="signup",
|
|
18
|
+
expected_hostname="example.com",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if not result.success:
|
|
22
|
+
# Reject the protected operation.
|
|
23
|
+
pass
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Keep the secret key on your server. Browser applications use the HumanPass widget and a public site key instead.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "valgix-humanpass"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python server SDK for Valgix HumanPass."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Valgix" }]
|
|
12
|
+
keywords = ["humanpass", "captcha", "security", "valgix"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Documentation = "https://docs.valgix.com/humanpass/"
|
|
27
|
+
Repository = "https://github.com/valgixcenter/humanpass-python"
|
|
28
|
+
Issues = "https://github.com/valgixcenter/humanpass-python/issues"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/humanpass"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
pythonpath = ["src"]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"build>=1.2",
|
|
40
|
+
"twine>=6.1",
|
|
41
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .client import HumanPass
|
|
2
|
+
from .errors import (
|
|
3
|
+
HumanPassApiError,
|
|
4
|
+
HumanPassConfigurationError,
|
|
5
|
+
HumanPassError,
|
|
6
|
+
HumanPassTransportError,
|
|
7
|
+
)
|
|
8
|
+
from .models import SiteverifyFailure, SiteverifyResult, SiteverifySuccess
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"HumanPass",
|
|
12
|
+
"HumanPassApiError",
|
|
13
|
+
"HumanPassConfigurationError",
|
|
14
|
+
"HumanPassError",
|
|
15
|
+
"HumanPassTransportError",
|
|
16
|
+
"SiteverifyFailure",
|
|
17
|
+
"SiteverifyResult",
|
|
18
|
+
"SiteverifySuccess",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, Protocol, cast
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
from .errors import (
|
|
13
|
+
HumanPassApiError,
|
|
14
|
+
HumanPassConfigurationError,
|
|
15
|
+
HumanPassTransportError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
SITEVERIFY_ERROR_CODES,
|
|
19
|
+
SiteverifyErrorCode,
|
|
20
|
+
SiteverifyFailure,
|
|
21
|
+
SiteverifyResult,
|
|
22
|
+
SiteverifySuccess,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://humanpass.valgix.com"
|
|
26
|
+
DEFAULT_TIMEOUT_SECONDS = 5.0
|
|
27
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
28
|
+
SECRET_PATTERN = re.compile(r"^hp_secret_(?:live|test)_[A-Za-z0-9_-]{16,}$")
|
|
29
|
+
RESPONSE_PATTERN = re.compile(r"^hp_response_[A-Za-z0-9._~-]{16,}$")
|
|
30
|
+
HOSTNAME_PATTERN = re.compile(
|
|
31
|
+
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
|
|
32
|
+
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HttpResponse(Protocol):
|
|
37
|
+
status: int
|
|
38
|
+
headers: Any
|
|
39
|
+
|
|
40
|
+
def read(self, amount: int = -1) -> bytes: ...
|
|
41
|
+
|
|
42
|
+
def __enter__(self) -> HttpResponse: ...
|
|
43
|
+
|
|
44
|
+
def __exit__(self, *args: object) -> None: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Transport = Callable[[Request, float], HttpResponse]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default_transport(request: Request, timeout: float) -> HttpResponse:
|
|
51
|
+
return cast(HttpResponse, urlopen(request, timeout=timeout))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_base_url(value: str) -> str:
|
|
55
|
+
parsed = urlsplit(value)
|
|
56
|
+
if parsed.scheme not in {"https", "http"} or not parsed.hostname:
|
|
57
|
+
raise HumanPassConfigurationError("HumanPass base_url must be a valid HTTP URL.")
|
|
58
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
59
|
+
raise HumanPassConfigurationError("HumanPass base_url is not allowed.")
|
|
60
|
+
if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
|
|
61
|
+
raise HumanPassConfigurationError(
|
|
62
|
+
"HumanPass base_url must use HTTPS outside local development."
|
|
63
|
+
)
|
|
64
|
+
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _normalize_hostname(value: str | None) -> str | None:
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
normalized = value.strip().lower().rstrip(".")
|
|
71
|
+
if not HOSTNAME_PATTERN.fullmatch(normalized):
|
|
72
|
+
raise HumanPassConfigurationError(
|
|
73
|
+
"expected_hostname must be a valid hostname without a port."
|
|
74
|
+
)
|
|
75
|
+
return normalized
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _bounded_json(response: HttpResponse) -> dict[str, Any]:
|
|
79
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
80
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
81
|
+
raise ValueError("oversized response")
|
|
82
|
+
value = json.loads(raw)
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
raise ValueError("non-object response")
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_result(payload: dict[str, Any]) -> SiteverifyResult | None:
|
|
89
|
+
if payload.get("success") is False:
|
|
90
|
+
codes = payload.get("error_codes")
|
|
91
|
+
if not isinstance(codes, list) or not codes or not all(
|
|
92
|
+
isinstance(code, str) and code in SITEVERIFY_ERROR_CODES for code in codes
|
|
93
|
+
):
|
|
94
|
+
return None
|
|
95
|
+
request_id = payload.get("request_id")
|
|
96
|
+
if request_id is not None and not isinstance(request_id, str):
|
|
97
|
+
return None
|
|
98
|
+
return SiteverifyFailure(
|
|
99
|
+
success=False,
|
|
100
|
+
error_codes=cast(tuple[SiteverifyErrorCode, ...], tuple(codes)),
|
|
101
|
+
request_id=request_id,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
required = {
|
|
105
|
+
"challenge_ts": str,
|
|
106
|
+
"hostname": str,
|
|
107
|
+
"action": str,
|
|
108
|
+
"sitekey": str,
|
|
109
|
+
"verification_id": str,
|
|
110
|
+
}
|
|
111
|
+
if payload.get("success") is not True or any(
|
|
112
|
+
not isinstance(payload.get(key), expected_type)
|
|
113
|
+
for key, expected_type in required.items()
|
|
114
|
+
):
|
|
115
|
+
return None
|
|
116
|
+
environment = payload.get("environment")
|
|
117
|
+
if environment not in {"live", "test"}:
|
|
118
|
+
return None
|
|
119
|
+
return SiteverifySuccess(
|
|
120
|
+
success=True,
|
|
121
|
+
challenge_ts=payload["challenge_ts"],
|
|
122
|
+
hostname=payload["hostname"],
|
|
123
|
+
action=payload["action"],
|
|
124
|
+
sitekey=payload["sitekey"],
|
|
125
|
+
environment=environment,
|
|
126
|
+
verification_id=payload["verification_id"],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class HumanPass:
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
secret: str,
|
|
135
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
136
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
137
|
+
transport: Transport | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
if not SECRET_PATTERN.fullmatch(secret):
|
|
140
|
+
raise HumanPassConfigurationError("HumanPass secret has an invalid format.")
|
|
141
|
+
if timeout < 0.1:
|
|
142
|
+
raise HumanPassConfigurationError("HumanPass timeout must be at least 0.1 seconds.")
|
|
143
|
+
self.base_url = _normalize_base_url(base_url)
|
|
144
|
+
self.timeout = timeout
|
|
145
|
+
self.__secret = secret
|
|
146
|
+
self.__transport = transport or _default_transport
|
|
147
|
+
|
|
148
|
+
def verify(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
response: str,
|
|
152
|
+
remote_ip: str | None = None,
|
|
153
|
+
idempotency_key: str | None = None,
|
|
154
|
+
expected_action: str | None = None,
|
|
155
|
+
expected_hostname: str | None = None,
|
|
156
|
+
) -> SiteverifyResult:
|
|
157
|
+
if not RESPONSE_PATTERN.fullmatch(response):
|
|
158
|
+
return SiteverifyFailure(success=False, error_codes=("invalid_input_response",))
|
|
159
|
+
normalized_hostname = _normalize_hostname(expected_hostname)
|
|
160
|
+
body = {
|
|
161
|
+
"response": response,
|
|
162
|
+
**({"remoteip": remote_ip} if remote_ip else {}),
|
|
163
|
+
**({"idempotency_key": idempotency_key} if idempotency_key else {}),
|
|
164
|
+
}
|
|
165
|
+
request = Request(
|
|
166
|
+
f"{self.base_url}/v1/siteverify",
|
|
167
|
+
data=json.dumps(body, separators=(",", ":")).encode(),
|
|
168
|
+
headers={
|
|
169
|
+
"Authorization": f"Bearer {self.__secret}",
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
"Accept": "application/json",
|
|
172
|
+
"User-Agent": "valgix-humanpass-python/0.1.0",
|
|
173
|
+
},
|
|
174
|
+
method="POST",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
status = 0
|
|
178
|
+
request_id: str | None = None
|
|
179
|
+
try:
|
|
180
|
+
try:
|
|
181
|
+
result = self.__transport(request, self.timeout)
|
|
182
|
+
with result as http_response:
|
|
183
|
+
status = http_response.status
|
|
184
|
+
request_id = http_response.headers.get("X-Request-ID")
|
|
185
|
+
payload = _bounded_json(http_response)
|
|
186
|
+
except HTTPError as error:
|
|
187
|
+
status = error.code
|
|
188
|
+
request_id = error.headers.get("X-Request-ID")
|
|
189
|
+
payload = _bounded_json(cast(HttpResponse, error))
|
|
190
|
+
except (HTTPError, URLError, TimeoutError, socket.timeout, OSError):
|
|
191
|
+
raise HumanPassTransportError("HumanPass verification request failed.") from None
|
|
192
|
+
except (ValueError, json.JSONDecodeError):
|
|
193
|
+
raise HumanPassApiError(
|
|
194
|
+
"HumanPass returned an invalid response.",
|
|
195
|
+
status=status,
|
|
196
|
+
request_id=request_id,
|
|
197
|
+
) from None
|
|
198
|
+
|
|
199
|
+
parsed = _parse_result(payload)
|
|
200
|
+
if isinstance(parsed, SiteverifyFailure):
|
|
201
|
+
return parsed
|
|
202
|
+
if status < 200 or status >= 300 or not isinstance(parsed, SiteverifySuccess):
|
|
203
|
+
code = payload.get("code") if isinstance(payload.get("code"), str) else None
|
|
204
|
+
raise HumanPassApiError(
|
|
205
|
+
"HumanPass returned an unexpected response.",
|
|
206
|
+
status=status,
|
|
207
|
+
request_id=request_id,
|
|
208
|
+
code=code,
|
|
209
|
+
)
|
|
210
|
+
if expected_action is not None and parsed.action != expected_action:
|
|
211
|
+
return SiteverifyFailure(
|
|
212
|
+
success=False,
|
|
213
|
+
error_codes=("action_mismatch",),
|
|
214
|
+
request_id=request_id,
|
|
215
|
+
)
|
|
216
|
+
if normalized_hostname is not None and parsed.hostname.lower().rstrip(".") != normalized_hostname:
|
|
217
|
+
return SiteverifyFailure(
|
|
218
|
+
success=False,
|
|
219
|
+
error_codes=("hostname_mismatch",),
|
|
220
|
+
request_id=request_id,
|
|
221
|
+
)
|
|
222
|
+
return parsed
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class HumanPassError(Exception):
|
|
2
|
+
"""Base class for sanitized HumanPass SDK failures."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.request_id = request_id
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HumanPassConfigurationError(HumanPassError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HumanPassTransportError(HumanPassError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HumanPassApiError(HumanPassError):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
status: int,
|
|
23
|
+
request_id: str | None = None,
|
|
24
|
+
code: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message, request_id=request_id)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal, TypeAlias
|
|
3
|
+
|
|
4
|
+
HumanPassEnvironment: TypeAlias = Literal["live", "test"]
|
|
5
|
+
SiteverifyErrorCode: TypeAlias = Literal[
|
|
6
|
+
"missing_input_response",
|
|
7
|
+
"invalid_input_response",
|
|
8
|
+
"timeout_or_duplicate",
|
|
9
|
+
"invalid_secret",
|
|
10
|
+
"site_disabled",
|
|
11
|
+
"action_mismatch",
|
|
12
|
+
"hostname_mismatch",
|
|
13
|
+
"invalid_idempotency_key",
|
|
14
|
+
"rate_limited",
|
|
15
|
+
"service_unavailable",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
SITEVERIFY_ERROR_CODES: frozenset[str] = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"missing_input_response",
|
|
21
|
+
"invalid_input_response",
|
|
22
|
+
"timeout_or_duplicate",
|
|
23
|
+
"invalid_secret",
|
|
24
|
+
"site_disabled",
|
|
25
|
+
"action_mismatch",
|
|
26
|
+
"hostname_mismatch",
|
|
27
|
+
"invalid_idempotency_key",
|
|
28
|
+
"rate_limited",
|
|
29
|
+
"service_unavailable",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SiteverifySuccess:
|
|
36
|
+
success: Literal[True]
|
|
37
|
+
challenge_ts: str
|
|
38
|
+
hostname: str
|
|
39
|
+
action: str
|
|
40
|
+
sitekey: str
|
|
41
|
+
environment: HumanPassEnvironment
|
|
42
|
+
verification_id: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class SiteverifyFailure:
|
|
47
|
+
success: Literal[False]
|
|
48
|
+
error_codes: tuple[SiteverifyErrorCode, ...]
|
|
49
|
+
request_id: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
SiteverifyResult: TypeAlias = SiteverifySuccess | SiteverifyFailure
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from humanpass import HumanPass, HumanPassApiError, HumanPassConfigurationError
|
|
6
|
+
|
|
7
|
+
SECRET = "hp_secret_live_1234567890abcdefgh"
|
|
8
|
+
TOKEN = "hp_response_1234567890abcdefgh"
|
|
9
|
+
SUCCESS = {
|
|
10
|
+
"success": True,
|
|
11
|
+
"challenge_ts": "2026-08-22T12:00:00.000Z",
|
|
12
|
+
"hostname": "example.com",
|
|
13
|
+
"action": "signup",
|
|
14
|
+
"sitekey": "hp_site_live_1234567890abcdefgh",
|
|
15
|
+
"environment": "live",
|
|
16
|
+
"verification_id": "hpv_1234567890abcdefgh",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Headers(dict[str, str]):
|
|
21
|
+
def get(self, key: str, default=None):
|
|
22
|
+
return super().get(key.lower(), default)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Response:
|
|
26
|
+
def __init__(self, payload, *, status=200):
|
|
27
|
+
self.status = status
|
|
28
|
+
self.headers = Headers({"x-request-id": "11111111-1111-4111-8111-111111111111"})
|
|
29
|
+
self.stream = io.BytesIO(json.dumps(payload).encode())
|
|
30
|
+
|
|
31
|
+
def read(self, amount=-1):
|
|
32
|
+
return self.stream.read(amount)
|
|
33
|
+
|
|
34
|
+
def __enter__(self):
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(self, *args):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HumanPassTests(unittest.TestCase):
|
|
42
|
+
def test_sends_secret_only_in_authorization_header(self):
|
|
43
|
+
captured = {}
|
|
44
|
+
|
|
45
|
+
def transport(request, timeout):
|
|
46
|
+
captured["request"] = request
|
|
47
|
+
captured["timeout"] = timeout
|
|
48
|
+
return Response(SUCCESS)
|
|
49
|
+
|
|
50
|
+
result = HumanPass(secret=SECRET, transport=transport).verify(
|
|
51
|
+
response=TOKEN,
|
|
52
|
+
remote_ip="203.0.113.10",
|
|
53
|
+
idempotency_key="signup-attempt-1",
|
|
54
|
+
)
|
|
55
|
+
request = captured["request"]
|
|
56
|
+
self.assertTrue(result.success)
|
|
57
|
+
self.assertEqual(request.get_header("Authorization"), f"Bearer {SECRET}")
|
|
58
|
+
self.assertNotIn(SECRET, request.data.decode())
|
|
59
|
+
|
|
60
|
+
def test_returns_semantic_action_mismatch(self):
|
|
61
|
+
client = HumanPass(secret=SECRET, transport=lambda *_: Response(SUCCESS))
|
|
62
|
+
result = client.verify(response=TOKEN, expected_action="checkout")
|
|
63
|
+
self.assertFalse(result.success)
|
|
64
|
+
self.assertEqual(result.error_codes, ("action_mismatch",))
|
|
65
|
+
|
|
66
|
+
def test_rejects_insecure_non_loopback_base_url(self):
|
|
67
|
+
with self.assertRaises(HumanPassConfigurationError):
|
|
68
|
+
HumanPass(secret=SECRET, base_url="http://example.com")
|
|
69
|
+
|
|
70
|
+
def test_rejects_oversized_api_response(self):
|
|
71
|
+
client = HumanPass(
|
|
72
|
+
secret=SECRET,
|
|
73
|
+
transport=lambda *_: Response({"padding": "x" * (70 * 1024)}),
|
|
74
|
+
)
|
|
75
|
+
with self.assertRaises(HumanPassApiError):
|
|
76
|
+
client.verify(response=TOKEN)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
unittest.main()
|