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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any