WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 3eee080

Browse files
Webhook verification (#49)
* add webhook verification and improve readme * add pr template * wip * wip
1 parent d2a08d8 commit 3eee080

File tree

6 files changed

+205
-5
lines changed

6 files changed

+205
-5
lines changed

.github/pull_request_template.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
### Breaking Changes
2+
<!-- Optional - List any backward incompatible changes -->
3+
4+
### New Features
5+
<!-- Optional - List new functionality added -->
6+
7+
### Bug Fixes
8+
<!-- Optional - List bugs fixed in this release -->
9+
10+
### Checklist
11+
- [ ] Version bumped
12+
- [ ] Documentation updated

authsignal/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
from .client import AuthsignalClient
2+
from .webhook import Webhook

authsignal/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from requests.adapters import HTTPAdapter
1010

1111
from authsignal.version import VERSION
12+
from authsignal.webhook import Webhook
1213

1314
API_BASE_URL = "https://api.authsignal.com/v1"
1415

@@ -98,7 +99,7 @@ class AuthsignalClient(object):
9899
def __init__(self, api_secret_key, api_url=API_BASE_URL, timeout=2.0):
99100
"""Initialize the client.
100101
Args:
101-
api_key: Your Authsignal Secret API key of your tenant
102+
api_secret_key: Your Authsignal Secret API key of your tenant
102103
api_url: Base URL, including scheme and host, for sending events.
103104
Defaults to 'https://api.authsignal.com/v1'.
104105
timeout: Number of seconds to wait before failing request. Defaults
@@ -112,6 +113,7 @@ def __init__(self, api_secret_key, api_url=API_BASE_URL, timeout=2.0):
112113

113114
self.session = CustomSession(timeout=timeout, api_key=api_secret_key)
114115
self.version = VERSION
116+
self.webhook = Webhook(api_secret_key=api_secret_key)
115117

116118
def track(
117119
self, user_id: str, action: str, attributes: Dict[str, Any] = None

authsignal/webhook.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import hmac
2+
import hashlib
3+
import base64
4+
import json
5+
import time
6+
from typing import List, Dict, Any
7+
8+
DEFAULT_TOLERANCE = 5 # minutes
9+
VERSION = "v2"
10+
11+
class InvalidSignatureError(Exception):
12+
pass
13+
14+
class Webhook:
15+
def __init__(self, api_secret_key: str):
16+
self.api_secret_key = api_secret_key
17+
18+
def construct_event(self, payload: str, signature: str, tolerance: int = DEFAULT_TOLERANCE) -> Dict[str, Any]:
19+
parsed_signature = self.parse_signature(signature)
20+
seconds_since_epoch = int(time.time())
21+
22+
if tolerance > 0 and parsed_signature["timestamp"] < seconds_since_epoch - tolerance * 60:
23+
raise InvalidSignatureError("Timestamp is outside the tolerance zone.")
24+
25+
hmac_content = f"{parsed_signature['timestamp']}.{payload}"
26+
computed_signature = base64.b64encode(
27+
hmac.new(
28+
self.api_secret_key.encode(),
29+
hmac_content.encode(),
30+
hashlib.sha256
31+
).digest()
32+
).decode().replace("=", "")
33+
34+
match = any(sig == computed_signature for sig in parsed_signature["signatures"])
35+
if not match:
36+
raise InvalidSignatureError("Signature mismatch.")
37+
38+
return json.loads(payload)
39+
40+
def parse_signature(self, value: str) -> Dict[str, Any]:
41+
timestamp = -1
42+
signatures: List[str] = []
43+
for item in value.split(","):
44+
kv = item.split("=")
45+
if kv[0] == "t":
46+
timestamp = int(kv[1])
47+
if kv[0] == VERSION:
48+
signatures.append(kv[1])
49+
if timestamp == -1 or not signatures:
50+
raise InvalidSignatureError("Signature format is invalid.")
51+
return {"timestamp": timestamp, "signatures": signatures}

authsignal/webhook_tests.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import unittest
2+
import time
3+
import base64
4+
import hmac
5+
import hashlib
6+
import json
7+
8+
from .webhook import Webhook, InvalidSignatureError
9+
10+
class TestWebhook(unittest.TestCase):
11+
def setUp(self):
12+
self.secret = "YOUR_AUTHSIGNAL_SECRET_KEY"
13+
self.webhook = Webhook(self.secret)
14+
self.payload_valid_signature = json.dumps({
15+
"version": 1,
16+
"id": "bc1598bc-e5d6-4c69-9afb-1a6fe3469d6e",
17+
"source": "https://authsignal.com",
18+
"time": "2025-02-20T01:51:56.070Z",
19+
"tenantId": "7752d28e-e627-4b1b-bb81-b45d68d617bc",
20+
"type": "email.created",
21+
"data": {
22+
23+
"code": "157743",
24+
"userId": "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0",
25+
"actionCode": "accountRecovery",
26+
"idempotencyKey": "ba8c1a7c-775d-4dff-9abe-be798b7b8bb9",
27+
"verificationMethod": "EMAIL_OTP",
28+
},
29+
})
30+
self.payload_multiple_keys = json.dumps({
31+
"version": 1,
32+
"id": "af7be03c-ea8f-4739-b18e-8b48fcbe4e38",
33+
"source": "https://authsignal.com",
34+
"time": "2025-02-20T01:47:17.248Z",
35+
"tenantId": "7752d28e-e627-4b1b-bb81-b45d68d617bc",
36+
"type": "email.created",
37+
"data": {
38+
39+
"code": "718190",
40+
"userId": "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0",
41+
"actionCode": "accountRecovery",
42+
"idempotencyKey": "68d68190-fac9-4e91-b277-c63d31d3c6b1",
43+
"verificationMethod": "EMAIL_OTP",
44+
},
45+
})
46+
self.timestamp = int(time.time())
47+
self.version = "v2"
48+
49+
def generate_signature(self, payload, timestamp=None, secret=None, extra_signatures=None):
50+
if timestamp is None:
51+
timestamp = self.timestamp
52+
if secret is None:
53+
secret = self.secret
54+
hmac_content = f"{timestamp}.{payload}"
55+
computed_signature = base64.b64encode(
56+
hmac.new(secret.encode(), hmac_content.encode(), hashlib.sha256).digest()
57+
).decode().replace("=", "")
58+
sigs = [f"{self.version}={computed_signature}"]
59+
if extra_signatures:
60+
sigs.extend([f"{self.version}={s}" for s in extra_signatures])
61+
return f"t={timestamp}," + ",".join(sigs)
62+
63+
def test_invalid_signature_format(self):
64+
with self.assertRaises(InvalidSignatureError) as cm:
65+
self.webhook.construct_event(self.payload_valid_signature, "123")
66+
self.assertEqual(str(cm.exception), "Signature format is invalid.")
67+
68+
def test_timestamp_tolerance_error(self):
69+
signature = "t=1630000000,v2=invalid_signature"
70+
with self.assertRaises(InvalidSignatureError) as cm:
71+
self.webhook.construct_event(self.payload_valid_signature, signature)
72+
self.assertEqual(str(cm.exception), "Timestamp is outside the tolerance zone.")
73+
74+
def test_invalid_computed_signature(self):
75+
timestamp = int(time.time())
76+
signature = f"t={timestamp},v2=invalid_signature"
77+
with self.assertRaises(InvalidSignatureError) as cm:
78+
self.webhook.construct_event(self.payload_valid_signature, signature)
79+
self.assertEqual(str(cm.exception), "Signature mismatch.")
80+
81+
def test_valid_signature(self):
82+
payload = self.payload_valid_signature
83+
timestamp = 1740016316
84+
signature = self.generate_signature(payload, timestamp=timestamp, secret=self.secret)
85+
86+
event = self.webhook.construct_event(payload, signature, tolerance=-1)
87+
self.assertIsNotNone(event)
88+
self.assertEqual(event["version"], 1)
89+
self.assertEqual(event["data"]["actionCode"], "accountRecovery")
90+
91+
def test_valid_signature_multiple_keys(self):
92+
93+
payload = self.payload_multiple_keys
94+
timestamp = 1740016037
95+
96+
valid_signature = self.generate_signature(payload, timestamp=timestamp, secret=self.secret).split(",")[1]
97+
98+
99+
signature = f"t={timestamp},{valid_signature},v2=dummyInvalidSignature"
100+
101+
event = self.webhook.construct_event(payload, signature, tolerance=-1)
102+
self.assertIsNotNone(event)
103+
self.assertEqual(event["version"], 1)
104+
self.assertEqual(event["data"]["actionCode"], "accountRecovery")
105+
106+
if __name__ == "__main__":
107+
unittest.main()

readme.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
# Authsignal Python SDK
44

5-
The Authsignal Python library for server-side applications.
5+
[![PyPI version](https://img.shields.io/pypi/v/authsignal.svg)](https://pypi.org/project/authsignal/)
6+
[![License](https://img.shields.io/github/license/authsignal/authsignal-python.svg)](https://github.com/authsignal/authsignal-python/blob/main/LICENSE)
67

7-
## Installation
8+
The official Authsignal Python library for server-side applications. Use this SDK to easily integrate Authsignal's multi-factor authentication (MFA) and passwordless features into your Python backend.
89

9-
Python 3
10+
## Installation
1011

1112
```bash
1213
pip3 install authsignal
@@ -18,6 +19,32 @@ or install newest source directly from GitHub:
1819
pip3 install git+https://github.com/authsignal/authsignal-python
1920
```
2021

22+
## Getting Started
23+
24+
Initialize the Authsignal client with your secret key from the [Authsignal Portal](https://portal.authsignal.com/) and the API URL for your region.
25+
26+
```python
27+
from authsignal import Authsignal
28+
29+
# Initialize the client
30+
authsignal = Authsignal(
31+
api_secret_key="your_secret_key",
32+
api_url="https://api.authsignal.com/v1" # Use region-specific URL
33+
)
34+
```
35+
36+
### API URLs by Region
37+
38+
| Region | API URL |
39+
| ----------- | -------------------------------- |
40+
| US (Oregon) | https://api.authsignal.com/v1 |
41+
| AU (Sydney) | https://au.api.authsignal.com/v1 |
42+
| EU (Dublin) | https://eu.api.authsignal.com/v1 |
43+
44+
## License
45+
46+
This SDK is licensed under the [MIT License](LICENSE).
47+
2148
## Documentation
2249

23-
Refer to our [SDK documentation](https://docs.authsignal.com/sdks/server) for information on how to use this SDK.
50+
For more information and advanced usage examples, refer to the official [Authsignal Server-Side SDK documentation](https://docs.authsignal.com/sdks/server/overview).

0 commit comments

Comments
 (0)