lessotp-sdk 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.
- lessotp_sdk-0.1.0/.gitignore +45 -0
- lessotp_sdk-0.1.0/PKG-INFO +103 -0
- lessotp_sdk-0.1.0/README.md +78 -0
- lessotp_sdk-0.1.0/pyproject.toml +42 -0
- lessotp_sdk-0.1.0/src/lessotp_sdk/__init__.py +216 -0
- lessotp_sdk-0.1.0/tests/test_client.py +187 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
vendor/
|
|
4
|
+
.venv/
|
|
5
|
+
venv/
|
|
6
|
+
.env/
|
|
7
|
+
|
|
8
|
+
# Build outputs
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
coverage/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
|
|
14
|
+
# Python
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
|
|
21
|
+
# JavaScript / package managers
|
|
22
|
+
.bun/
|
|
23
|
+
.npm/
|
|
24
|
+
.pnpm-store/
|
|
25
|
+
.yarn/
|
|
26
|
+
*.tsbuildinfo
|
|
27
|
+
|
|
28
|
+
# Logs
|
|
29
|
+
*.log
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
33
|
+
pnpm-debug.log*
|
|
34
|
+
|
|
35
|
+
# OS / editors
|
|
36
|
+
.DS_Store
|
|
37
|
+
Thumbs.db
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
|
|
41
|
+
# Local env
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.*.example
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lessotp-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LessOTP Inbound WhatsApp Authentication client SDK for Python.
|
|
5
|
+
Project-URL: Homepage, https://lessotp.com
|
|
6
|
+
Project-URL: Repository, https://github.com/lessotp/sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/lessotp/sdk/issues
|
|
8
|
+
Author: LessOTP
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: authentication,lessotp,passwordless,verification,whatsapp
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# LessOTP Python SDK
|
|
27
|
+
|
|
28
|
+
Client for the **LessOTP Inbound WhatsApp Authentication API** (Python 3.8+).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install lessotp-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
from lessotp_sdk import LessOTPClient, parse_verified_webhook
|
|
41
|
+
|
|
42
|
+
# production (default)
|
|
43
|
+
client = LessOTPClient(api_key=os.environ["LESSOTP_API_KEY"])
|
|
44
|
+
|
|
45
|
+
# staging
|
|
46
|
+
staging = LessOTPClient(
|
|
47
|
+
api_key=os.environ["LESSOTP_STAGING_API_KEY"],
|
|
48
|
+
environment="staging",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# strict
|
|
52
|
+
strict = client.auth_request("6281234567890")
|
|
53
|
+
|
|
54
|
+
# frictionless
|
|
55
|
+
frictionless = client.auth_request()
|
|
56
|
+
|
|
57
|
+
# per-call override
|
|
58
|
+
one_off = client.auth_request("6281234567890", environment="staging")
|
|
59
|
+
|
|
60
|
+
# webhook verification
|
|
61
|
+
event = parse_verified_webhook(
|
|
62
|
+
request.get_data(as_text=True),
|
|
63
|
+
request.headers.get("X-Signature"),
|
|
64
|
+
os.environ["LESSOTP_WEBHOOK_SECRET"],
|
|
65
|
+
)
|
|
66
|
+
if event is None:
|
|
67
|
+
return "bad signature", 403
|
|
68
|
+
print(event.request_id, event.phone_number)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
### `LessOTPClient(api_key, environment='production', base_url='https://api.lessotp.com', timeout_seconds=10)`
|
|
74
|
+
|
|
75
|
+
| Option | Default | Description |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `api_key` | required | App API key. |
|
|
78
|
+
| `environment` | `"production"` | `"production"` or `"staging"`. |
|
|
79
|
+
| `base_url` | `https://api.lessotp.com` | API host. |
|
|
80
|
+
| `timeout_seconds` | `10` | HTTP timeout. |
|
|
81
|
+
|
|
82
|
+
### `client.auth_request(phone_number=None, environment=None) -> AuthRequestResult`
|
|
83
|
+
|
|
84
|
+
Calls the endpoint selected by `environment`. The per-call `environment` overrides the client environment.
|
|
85
|
+
|
|
86
|
+
### `verify_webhook_signature(raw_body, signature_header, secret) -> bool`
|
|
87
|
+
|
|
88
|
+
Constant-time HMAC-SHA256 verification. Accepts raw hex and `sha256=` prefixed values.
|
|
89
|
+
|
|
90
|
+
### `parse_verified_webhook(raw_body, signature_header, secret) -> VerificationSuccess | None`
|
|
91
|
+
|
|
92
|
+
Returns the parsed payload if the signature is valid; `None` otherwise.
|
|
93
|
+
|
|
94
|
+
## Errors
|
|
95
|
+
|
|
96
|
+
Raises `LessOTPError` on transport, auth, or payload problems.
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m pip install -e ".[test]"
|
|
102
|
+
python -m pytest
|
|
103
|
+
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# LessOTP Python SDK
|
|
2
|
+
|
|
3
|
+
Client for the **LessOTP Inbound WhatsApp Authentication API** (Python 3.8+).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install lessotp-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from lessotp_sdk import LessOTPClient, parse_verified_webhook
|
|
16
|
+
|
|
17
|
+
# production (default)
|
|
18
|
+
client = LessOTPClient(api_key=os.environ["LESSOTP_API_KEY"])
|
|
19
|
+
|
|
20
|
+
# staging
|
|
21
|
+
staging = LessOTPClient(
|
|
22
|
+
api_key=os.environ["LESSOTP_STAGING_API_KEY"],
|
|
23
|
+
environment="staging",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# strict
|
|
27
|
+
strict = client.auth_request("6281234567890")
|
|
28
|
+
|
|
29
|
+
# frictionless
|
|
30
|
+
frictionless = client.auth_request()
|
|
31
|
+
|
|
32
|
+
# per-call override
|
|
33
|
+
one_off = client.auth_request("6281234567890", environment="staging")
|
|
34
|
+
|
|
35
|
+
# webhook verification
|
|
36
|
+
event = parse_verified_webhook(
|
|
37
|
+
request.get_data(as_text=True),
|
|
38
|
+
request.headers.get("X-Signature"),
|
|
39
|
+
os.environ["LESSOTP_WEBHOOK_SECRET"],
|
|
40
|
+
)
|
|
41
|
+
if event is None:
|
|
42
|
+
return "bad signature", 403
|
|
43
|
+
print(event.request_id, event.phone_number)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
### `LessOTPClient(api_key, environment='production', base_url='https://api.lessotp.com', timeout_seconds=10)`
|
|
49
|
+
|
|
50
|
+
| Option | Default | Description |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `api_key` | required | App API key. |
|
|
53
|
+
| `environment` | `"production"` | `"production"` or `"staging"`. |
|
|
54
|
+
| `base_url` | `https://api.lessotp.com` | API host. |
|
|
55
|
+
| `timeout_seconds` | `10` | HTTP timeout. |
|
|
56
|
+
|
|
57
|
+
### `client.auth_request(phone_number=None, environment=None) -> AuthRequestResult`
|
|
58
|
+
|
|
59
|
+
Calls the endpoint selected by `environment`. The per-call `environment` overrides the client environment.
|
|
60
|
+
|
|
61
|
+
### `verify_webhook_signature(raw_body, signature_header, secret) -> bool`
|
|
62
|
+
|
|
63
|
+
Constant-time HMAC-SHA256 verification. Accepts raw hex and `sha256=` prefixed values.
|
|
64
|
+
|
|
65
|
+
### `parse_verified_webhook(raw_body, signature_header, secret) -> VerificationSuccess | None`
|
|
66
|
+
|
|
67
|
+
Returns the parsed payload if the signature is valid; `None` otherwise.
|
|
68
|
+
|
|
69
|
+
## Errors
|
|
70
|
+
|
|
71
|
+
Raises `LessOTPError` on transport, auth, or payload problems.
|
|
72
|
+
|
|
73
|
+
## Tests
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python -m pip install -e ".[test]"
|
|
77
|
+
python -m pytest
|
|
78
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lessotp-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LessOTP Inbound WhatsApp Authentication client SDK for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "LessOTP" }]
|
|
13
|
+
keywords = ["lessotp", "whatsapp", "authentication", "verification", "passwordless"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=8.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://lessotp.com"
|
|
34
|
+
Repository = "https://github.com/lessotp/sdk"
|
|
35
|
+
Issues = "https://github.com/lessotp/sdk/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/lessotp_sdk"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""LessOTP Inbound WhatsApp Authentication API — client SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, Optional, Union
|
|
10
|
+
from urllib import error as urlerror
|
|
11
|
+
from urllib import request as urlrequest
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.lessotp.com"
|
|
14
|
+
DEFAULT_TIMEOUT_SECONDS = 10
|
|
15
|
+
DEFAULT_ENVIRONMENT = "production"
|
|
16
|
+
VALID_ENVIRONMENTS = ("production", "staging")
|
|
17
|
+
RawBody = Union[str, bytes]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LessOTPError(RuntimeError):
|
|
21
|
+
"""Surface error type for SDK consumers."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AuthRequestResult:
|
|
26
|
+
"""Normalized response from ``POST /api/v1/auth/request``."""
|
|
27
|
+
|
|
28
|
+
request_id: str
|
|
29
|
+
unique_code: str
|
|
30
|
+
wa_link: str
|
|
31
|
+
expires_in: int
|
|
32
|
+
mode: str
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_response(cls, payload: Dict[str, Any]) -> "AuthRequestResult":
|
|
36
|
+
data = payload.get("data")
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
raise LessOTPError("LessOTP response missing 'data' object")
|
|
39
|
+
required = ["request_id", "unique_code", "wa_link", "expires_in", "mode"]
|
|
40
|
+
for key in required:
|
|
41
|
+
if key not in data:
|
|
42
|
+
raise LessOTPError("LessOTP response missing '%s'" % key)
|
|
43
|
+
mode = str(data["mode"])
|
|
44
|
+
if mode not in {"strict", "frictionless"}:
|
|
45
|
+
raise LessOTPError(
|
|
46
|
+
"LessOTP response mode must be 'strict' or 'frictionless', got '%s'" % mode
|
|
47
|
+
)
|
|
48
|
+
return cls(
|
|
49
|
+
request_id=str(data["request_id"]),
|
|
50
|
+
unique_code=str(data["unique_code"]),
|
|
51
|
+
wa_link=str(data["wa_link"]),
|
|
52
|
+
expires_in=int(data["expires_in"]),
|
|
53
|
+
mode=mode,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class VerificationSuccess:
|
|
59
|
+
"""Canonical representation of a ``verification.success`` webhook payload."""
|
|
60
|
+
|
|
61
|
+
event: str
|
|
62
|
+
request_id: str
|
|
63
|
+
phone_number: str
|
|
64
|
+
timestamp: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_payload(cls, payload: Dict[str, Any]) -> "VerificationSuccess":
|
|
68
|
+
event = str(payload.get("event", ""))
|
|
69
|
+
if event != "verification.success":
|
|
70
|
+
raise LessOTPError("unexpected webhook event '%s'" % event)
|
|
71
|
+
request_id = payload.get("request_id")
|
|
72
|
+
phone_number = payload.get("phone_number")
|
|
73
|
+
if not isinstance(request_id, str) or not isinstance(phone_number, str):
|
|
74
|
+
raise LessOTPError("webhook payload missing request_id or phone_number")
|
|
75
|
+
timestamp = payload.get("timestamp")
|
|
76
|
+
return cls(
|
|
77
|
+
event=event,
|
|
78
|
+
request_id=request_id,
|
|
79
|
+
phone_number=phone_number,
|
|
80
|
+
timestamp=timestamp if isinstance(timestamp, str) else None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LessOTPClient:
|
|
85
|
+
"""Stateless HTTP client for the LessOTP API."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
api_key: str,
|
|
90
|
+
environment: str = DEFAULT_ENVIRONMENT,
|
|
91
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
92
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
93
|
+
) -> None:
|
|
94
|
+
if not api_key:
|
|
95
|
+
raise LessOTPError("api_key is required")
|
|
96
|
+
self._api_key = api_key
|
|
97
|
+
self._environment = _resolve_environment(environment)
|
|
98
|
+
self._base_url = base_url.rstrip("/")
|
|
99
|
+
self._timeout_seconds = timeout_seconds
|
|
100
|
+
|
|
101
|
+
def auth_request(
|
|
102
|
+
self,
|
|
103
|
+
phone_number: Optional[str] = None,
|
|
104
|
+
environment: Optional[str] = None,
|
|
105
|
+
) -> AuthRequestResult:
|
|
106
|
+
"""Create a verification request.
|
|
107
|
+
|
|
108
|
+
Endpoint is selected from the client environment. Pass ``environment``
|
|
109
|
+
to override for this call.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
env = _resolve_environment(environment if environment is not None else self._environment)
|
|
113
|
+
return self._auth_request(_endpoint_for(env), phone_number)
|
|
114
|
+
|
|
115
|
+
def _auth_request(self, path: str, phone_number: Optional[str]) -> AuthRequestResult:
|
|
116
|
+
body = {} if phone_number is None else {"phone_number": phone_number}
|
|
117
|
+
raw_body = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
118
|
+
req = urlrequest.Request(
|
|
119
|
+
self._base_url + path,
|
|
120
|
+
data=raw_body,
|
|
121
|
+
method="POST",
|
|
122
|
+
headers={
|
|
123
|
+
"Authorization": "Bearer %s" % self._api_key,
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
"Accept": "application/json",
|
|
126
|
+
"User-Agent": "lessotp-sdk-python/0.1.0",
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
with urlrequest.urlopen(req, timeout=self._timeout_seconds) as response:
|
|
131
|
+
status = getattr(response, "status", response.getcode())
|
|
132
|
+
raw = response.read()
|
|
133
|
+
except urlerror.HTTPError as exc:
|
|
134
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
135
|
+
raise LessOTPError("LessOTP auth_request failed: %s: %s" % (exc.code, raw)) from exc
|
|
136
|
+
except urlerror.URLError as exc:
|
|
137
|
+
raise LessOTPError("LessOTP auth_request transport error: %s" % exc.reason) from exc
|
|
138
|
+
|
|
139
|
+
if status < 200 or status >= 300:
|
|
140
|
+
raise LessOTPError(
|
|
141
|
+
"LessOTP auth_request failed: %s: %s"
|
|
142
|
+
% (status, raw.decode("utf-8", errors="replace"))
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
raise LessOTPError("LessOTP response was not valid JSON") from exc
|
|
149
|
+
if not isinstance(payload, dict) or payload.get("status") != "success":
|
|
150
|
+
raise LessOTPError("LessOTP response missing 'status: success'")
|
|
151
|
+
return AuthRequestResult.from_response(payload)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_environment(value: Optional[str]) -> str:
|
|
155
|
+
if value is None or value == "":
|
|
156
|
+
return "production"
|
|
157
|
+
if value not in VALID_ENVIRONMENTS:
|
|
158
|
+
raise LessOTPError(
|
|
159
|
+
"LessOTP environment must be 'production' or 'staging', got '%s'" % value
|
|
160
|
+
)
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _endpoint_for(environment: str) -> str:
|
|
165
|
+
if environment == "staging":
|
|
166
|
+
return "/api/v1/staging/auth/request"
|
|
167
|
+
return "/api/v1/auth/request"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def verify_webhook_signature(
|
|
171
|
+
raw_body: RawBody,
|
|
172
|
+
signature_header: Optional[str],
|
|
173
|
+
secret: str,
|
|
174
|
+
) -> bool:
|
|
175
|
+
"""Return true when ``signature_header`` is a valid HMAC-SHA256."""
|
|
176
|
+
|
|
177
|
+
if not signature_header or not secret:
|
|
178
|
+
return False
|
|
179
|
+
stripped = signature_header.strip()
|
|
180
|
+
if stripped.lower().startswith("sha256="):
|
|
181
|
+
stripped = stripped[7:]
|
|
182
|
+
if len(stripped) == 0 or len(stripped) % 2 != 0:
|
|
183
|
+
return False
|
|
184
|
+
try:
|
|
185
|
+
provided = bytes.fromhex(stripped)
|
|
186
|
+
except ValueError:
|
|
187
|
+
return False
|
|
188
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
189
|
+
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).digest()
|
|
190
|
+
return hmac.compare_digest(expected, provided)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_verified_webhook(
|
|
194
|
+
raw_body: RawBody,
|
|
195
|
+
signature_header: Optional[str],
|
|
196
|
+
secret: str,
|
|
197
|
+
) -> Optional[VerificationSuccess]:
|
|
198
|
+
"""Verify and parse a LessOTP ``verification.success`` webhook payload."""
|
|
199
|
+
|
|
200
|
+
if not verify_webhook_signature(raw_body, signature_header, secret):
|
|
201
|
+
return None
|
|
202
|
+
text = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
|
|
203
|
+
payload = json.loads(text)
|
|
204
|
+
if not isinstance(payload, dict):
|
|
205
|
+
raise LessOTPError("webhook payload is not an object")
|
|
206
|
+
return VerificationSuccess.from_payload(payload)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = [
|
|
210
|
+
"AuthRequestResult",
|
|
211
|
+
"LessOTPClient",
|
|
212
|
+
"LessOTPError",
|
|
213
|
+
"VerificationSuccess",
|
|
214
|
+
"parse_verified_webhook",
|
|
215
|
+
"verify_webhook_signature",
|
|
216
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
import threading
|
|
8
|
+
from urllib import error as urlerror
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from lessotp_sdk import (
|
|
13
|
+
DEFAULT_ENVIRONMENT,
|
|
14
|
+
LessOTPClient,
|
|
15
|
+
LessOTPError,
|
|
16
|
+
parse_verified_webhook,
|
|
17
|
+
verify_webhook_signature,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
22
|
+
status = 200
|
|
23
|
+
response = {
|
|
24
|
+
"status": "success",
|
|
25
|
+
"data": {
|
|
26
|
+
"request_id": "req_abc",
|
|
27
|
+
"unique_code": "A7X92",
|
|
28
|
+
"wa_link": "https://wa.me/628999999999?text=%2FLOGIN%20A7X92",
|
|
29
|
+
"expires_in": 180,
|
|
30
|
+
"mode": "strict",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
requests: list = []
|
|
34
|
+
|
|
35
|
+
def do_POST(self) -> None: # noqa: N802
|
|
36
|
+
length = int(self.headers.get("content-length", "0"))
|
|
37
|
+
body = self.rfile.read(length).decode("utf-8")
|
|
38
|
+
self.__class__.requests.append(
|
|
39
|
+
{
|
|
40
|
+
"path": self.path,
|
|
41
|
+
"authorization": self.headers.get("authorization", ""),
|
|
42
|
+
"body": body,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
raw = json.dumps(self.__class__.response).encode("utf-8")
|
|
46
|
+
self.send_response(self.__class__.status)
|
|
47
|
+
self.send_header("content-type", "application/json")
|
|
48
|
+
self.send_header("content-length", str(len(raw)))
|
|
49
|
+
self.end_headers()
|
|
50
|
+
self.wfile.write(raw)
|
|
51
|
+
|
|
52
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def server():
|
|
58
|
+
_Handler.status = 200
|
|
59
|
+
_Handler.response = {
|
|
60
|
+
"status": "success",
|
|
61
|
+
"data": {
|
|
62
|
+
"request_id": "req_abc",
|
|
63
|
+
"unique_code": "A7X92",
|
|
64
|
+
"wa_link": "https://wa.me/628999999999?text=%2FLOGIN%20A7X92",
|
|
65
|
+
"expires_in": 180,
|
|
66
|
+
"mode": "strict",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
_Handler.requests = []
|
|
70
|
+
srv = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
71
|
+
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
72
|
+
thread.start()
|
|
73
|
+
try:
|
|
74
|
+
yield srv
|
|
75
|
+
finally:
|
|
76
|
+
srv.shutdown()
|
|
77
|
+
thread.join(timeout=2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _url(server: HTTPServer) -> str:
|
|
81
|
+
return "http://127.0.0.1:%d" % server.server_port
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_default_environment_is_production(server: HTTPServer) -> None:
|
|
85
|
+
client = LessOTPClient("key_test", base_url=_url(server))
|
|
86
|
+
assert client._environment == DEFAULT_ENVIRONMENT == "production"
|
|
87
|
+
|
|
88
|
+
client.auth_request("6281234567890")
|
|
89
|
+
assert _Handler.requests[0]["path"] == "/api/v1/auth/request"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_constructor_environment_routes_to_staging(server: HTTPServer) -> None:
|
|
93
|
+
client = LessOTPClient("key_test", environment="staging", base_url=_url(server))
|
|
94
|
+
_Handler.response["data"]["request_id"] = "req_stage"
|
|
95
|
+
|
|
96
|
+
result = client.auth_request("6281234567890")
|
|
97
|
+
assert result.request_id == "req_stage"
|
|
98
|
+
assert _Handler.requests[0]["path"] == "/api/v1/staging/auth/request"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_per_call_environment_override_beats_constructor(server: HTTPServer) -> None:
|
|
102
|
+
client = LessOTPClient("key_test", base_url=_url(server))
|
|
103
|
+
_Handler.response["data"]["request_id"] = "req_stage"
|
|
104
|
+
|
|
105
|
+
client.auth_request("6281234567890", environment="staging")
|
|
106
|
+
assert _Handler.requests[0]["path"] == "/api/v1/staging/auth/request"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_auth_request_strict_posts_to_production_endpoint(server: HTTPServer) -> None:
|
|
110
|
+
client = LessOTPClient("key_test", base_url=_url(server))
|
|
111
|
+
result = client.auth_request("6281234567890")
|
|
112
|
+
|
|
113
|
+
assert result.request_id == "req_abc"
|
|
114
|
+
assert result.mode == "strict"
|
|
115
|
+
assert _Handler.requests[0]["path"] == "/api/v1/auth/request"
|
|
116
|
+
assert _Handler.requests[0]["authorization"] == "Bearer key_test"
|
|
117
|
+
assert json.loads(_Handler.requests[0]["body"]) == {"phone_number": "6281234567890"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_auth_request_frictionless_sends_empty_object(server: HTTPServer) -> None:
|
|
121
|
+
_Handler.response["data"]["mode"] = "frictionless"
|
|
122
|
+
client = LessOTPClient("key_test", base_url=_url(server))
|
|
123
|
+
result = client.auth_request()
|
|
124
|
+
assert result.mode == "frictionless"
|
|
125
|
+
assert json.loads(_Handler.requests[0]["body"]) == {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_non_2xx_raises_lessotp_error(server: HTTPServer) -> None:
|
|
129
|
+
_Handler.status = 401
|
|
130
|
+
_Handler.response = {"error": "invalid_api_key"}
|
|
131
|
+
client = LessOTPClient("bad", base_url=_url(server))
|
|
132
|
+
with pytest.raises(LessOTPError, match="401"):
|
|
133
|
+
client.auth_request()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_unknown_environment_raises(server: HTTPServer) -> None:
|
|
137
|
+
with pytest.raises(LessOTPError, match="environment must be"):
|
|
138
|
+
LessOTPClient("k", environment="qa", base_url=_url(server))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_verify_webhook_signature_accepts_hex_and_prefix() -> None:
|
|
142
|
+
body = json.dumps({"event": "verification.success", "request_id": "r1", "phone_number": "628"})
|
|
143
|
+
secret = "whsec_test"
|
|
144
|
+
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
145
|
+
|
|
146
|
+
assert verify_webhook_signature(body, sig, secret) is True
|
|
147
|
+
assert verify_webhook_signature(body, "sha256=" + sig, secret) is True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_verify_webhook_signature_rejects_bad_inputs() -> None:
|
|
151
|
+
assert verify_webhook_signature("{}", None, "secret") is False
|
|
152
|
+
assert verify_webhook_signature("{}", "abc", "secret") is False
|
|
153
|
+
assert verify_webhook_signature("{}", "a" * 64, "secret") is False
|
|
154
|
+
assert verify_webhook_signature("{}", "a" * 64, "") is False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_parse_verified_webhook_returns_none_on_bad_signature() -> None:
|
|
158
|
+
body = json.dumps({"event": "verification.success", "request_id": "r1", "phone_number": "628"})
|
|
159
|
+
assert parse_verified_webhook(body, None, "secret") is None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_parse_verified_webhook_parses_valid_payload() -> None:
|
|
163
|
+
payload = {
|
|
164
|
+
"event": "verification.success",
|
|
165
|
+
"request_id": "r1",
|
|
166
|
+
"phone_number": "6281234567890",
|
|
167
|
+
"timestamp": "2026-06-20T10:00:00Z",
|
|
168
|
+
}
|
|
169
|
+
body = json.dumps(payload)
|
|
170
|
+
secret = "secret"
|
|
171
|
+
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
172
|
+
|
|
173
|
+
event = parse_verified_webhook(body, sig, secret)
|
|
174
|
+
assert event is not None
|
|
175
|
+
assert event.request_id == "r1"
|
|
176
|
+
assert event.phone_number == "6281234567890"
|
|
177
|
+
assert event.timestamp == "2026-06-20T10:00:00Z"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_parse_verified_webhook_rejects_wrong_event() -> None:
|
|
181
|
+
payload = {"event": "verification.failed", "request_id": "r1", "phone_number": "628"}
|
|
182
|
+
body = json.dumps(payload)
|
|
183
|
+
secret = "secret"
|
|
184
|
+
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
185
|
+
|
|
186
|
+
with pytest.raises(LessOTPError, match="unexpected webhook event"):
|
|
187
|
+
parse_verified_webhook(body, sig, secret)
|