lessotp-sdk 0.1.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.
lessotp_sdk/__init__.py
ADDED
|
@@ -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,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,4 @@
|
|
|
1
|
+
lessotp_sdk/__init__.py,sha256=-N8drS8hg7W2K562G8358B2OeHnsIC6gZ8ejrD9jY5A,7507
|
|
2
|
+
lessotp_sdk-0.1.0.dist-info/METADATA,sha256=1meM89JBRyxOejJFDystf-2iWIQYwbUir8byT789dLw,2982
|
|
3
|
+
lessotp_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
lessotp_sdk-0.1.0.dist-info/RECORD,,
|