From 22d8862505164c3786532386ede047b4e0fd5a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAshutosh-Bhadauriya=E2=80=9D?= Date: Mon, 21 Apr 2025 08:46:20 +0530 Subject: [PATCH 1/4] add webhook verification and improve readme --- authsignal/__init__.py | 1 + authsignal/webhook.py | 51 +++++++++++++++++ authsignal/webhook_tests.py | 107 ++++++++++++++++++++++++++++++++++++ readme.md | 35 ++++++++++-- 4 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 authsignal/webhook.py create mode 100644 authsignal/webhook_tests.py diff --git a/authsignal/__init__.py b/authsignal/__init__.py index 34e20ae..50e4421 100644 --- a/authsignal/__init__.py +++ b/authsignal/__init__.py @@ -1 +1,2 @@ from .client import AuthsignalClient +from .webhook import Webhook diff --git a/authsignal/webhook.py b/authsignal/webhook.py new file mode 100644 index 0000000..d269861 --- /dev/null +++ b/authsignal/webhook.py @@ -0,0 +1,51 @@ +import hmac +import hashlib +import base64 +import json +import time +from typing import List, Dict, Any + +DEFAULT_TOLERANCE = 5 # minutes +VERSION = "v2" + +class InvalidSignatureError(Exception): + pass + +class Webhook: + def __init__(self, api_secret_key: str): + self.api_secret_key = api_secret_key + + def construct_event(self, payload: str, signature: str, tolerance: int = DEFAULT_TOLERANCE) -> Dict[str, Any]: + parsed_signature = self.parse_signature(signature) + seconds_since_epoch = int(time.time()) + + if tolerance > 0 and parsed_signature["timestamp"] < seconds_since_epoch - tolerance * 60: + raise InvalidSignatureError("Timestamp is outside the tolerance zone.") + + hmac_content = f"{parsed_signature['timestamp']}.{payload}" + computed_signature = base64.b64encode( + hmac.new( + self.api_secret_key.encode(), + hmac_content.encode(), + hashlib.sha256 + ).digest() + ).decode().replace("=", "") + + match = any(sig == computed_signature for sig in parsed_signature["signatures"]) + if not match: + raise InvalidSignatureError("Signature mismatch.") + + return json.loads(payload) + + def parse_signature(self, value: str) -> Dict[str, Any]: + timestamp = -1 + signatures: List[str] = [] + for item in value.split(","): + kv = item.split("=") + if kv[0] == "t": + timestamp = int(kv[1]) + if kv[0] == VERSION: + signatures.append(kv[1]) + if timestamp == -1 or not signatures: + raise InvalidSignatureError("Signature format is invalid.") + return {"timestamp": timestamp, "signatures": signatures} \ No newline at end of file diff --git a/authsignal/webhook_tests.py b/authsignal/webhook_tests.py new file mode 100644 index 0000000..6223507 --- /dev/null +++ b/authsignal/webhook_tests.py @@ -0,0 +1,107 @@ +import unittest +import time +import base64 +import hmac +import hashlib +import json + +from .webhook import Webhook, InvalidSignatureError + +class TestWebhook(unittest.TestCase): + def setUp(self): + self.secret = "YOUR_AUTHISGNAL_SECRET_KEY" + self.webhook = Webhook(self.secret) + self.payload_valid_signature = json.dumps({ + "version": 1, + "id": "bc1598bc-e5d6-4c69-9afb-1a6fe3469d6e", + "source": "https://authsignal.com", + "time": "2025-02-20T01:51:56.070Z", + "tenantId": "7752d28e-e627-4b1b-bb81-b45d68d617bc", + "type": "email.created", + "data": { + "to": "chris@authsignal.com", + "code": "157743", + "userId": "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0", + "actionCode": "accountRecovery", + "idempotencyKey": "ba8c1a7c-775d-4dff-9abe-be798b7b8bb9", + "verificationMethod": "EMAIL_OTP", + }, + }) + self.payload_multiple_keys = json.dumps({ + "version": 1, + "id": "af7be03c-ea8f-4739-b18e-8b48fcbe4e38", + "source": "https://authsignal.com", + "time": "2025-02-20T01:47:17.248Z", + "tenantId": "7752d28e-e627-4b1b-bb81-b45d68d617bc", + "type": "email.created", + "data": { + "to": "chris@authsignal.com", + "code": "718190", + "userId": "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0", + "actionCode": "accountRecovery", + "idempotencyKey": "68d68190-fac9-4e91-b277-c63d31d3c6b1", + "verificationMethod": "EMAIL_OTP", + }, + }) + self.timestamp = int(time.time()) + self.version = "v2" + + def generate_signature(self, payload, timestamp=None, secret=None, extra_signatures=None): + if timestamp is None: + timestamp = self.timestamp + if secret is None: + secret = self.secret + hmac_content = f"{timestamp}.{payload}" + computed_signature = base64.b64encode( + hmac.new(secret.encode(), hmac_content.encode(), hashlib.sha256).digest() + ).decode().replace("=", "") + sigs = [f"{self.version}={computed_signature}"] + if extra_signatures: + sigs.extend([f"{self.version}={s}" for s in extra_signatures]) + return f"t={timestamp}," + ",".join(sigs) + + def test_invalid_signature_format(self): + with self.assertRaises(InvalidSignatureError) as cm: + self.webhook.construct_event(self.payload_valid_signature, "123") + self.assertEqual(str(cm.exception), "Signature format is invalid.") + + def test_timestamp_tolerance_error(self): + signature = "t=1630000000,v2=invalid_signature" + with self.assertRaises(InvalidSignatureError) as cm: + self.webhook.construct_event(self.payload_valid_signature, signature) + self.assertEqual(str(cm.exception), "Timestamp is outside the tolerance zone.") + + def test_invalid_computed_signature(self): + timestamp = int(time.time()) + signature = f"t={timestamp},v2=invalid_signature" + with self.assertRaises(InvalidSignatureError) as cm: + self.webhook.construct_event(self.payload_valid_signature, signature) + self.assertEqual(str(cm.exception), "Signature mismatch.") + + def test_valid_signature(self): + payload = self.payload_valid_signature + timestamp = 1740016316 + signature = self.generate_signature(payload, timestamp=timestamp, secret=self.secret) + + event = self.webhook.construct_event(payload, signature, tolerance=-1) + self.assertIsNotNone(event) + self.assertEqual(event["version"], 1) + self.assertEqual(event["data"]["actionCode"], "accountRecovery") + + def test_valid_signature_multiple_keys(self): + + payload = self.payload_multiple_keys + timestamp = 1740016037 + + valid_signature = self.generate_signature(payload, timestamp=timestamp, secret=self.secret).split(",")[1] + + + signature = f"t={timestamp},{valid_signature},v2=dummyInvalidSignature" + + event = self.webhook.construct_event(payload, signature, tolerance=-1) + self.assertIsNotNone(event) + self.assertEqual(event["version"], 1) + self.assertEqual(event["data"]["actionCode"], "accountRecovery") + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/readme.md b/readme.md index 13947e8..87afbbd 100644 --- a/readme.md +++ b/readme.md @@ -2,11 +2,12 @@ # Authsignal Python SDK -The Authsignal Python library for server-side applications. +[![PyPI version](https://img.shields.io/pypi/v/authsignal.svg)](https://pypi.org/project/authsignal/) +[![License](https://img.shields.io/github/license/authsignal/authsignal-python.svg)](https://github.com/authsignal/authsignal-python/blob/main/LICENSE) -## Installation +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. -Python 3 +## Installation ```bash pip3 install authsignal @@ -18,6 +19,32 @@ or install newest source directly from GitHub: pip3 install git+https://github.com/authsignal/authsignal-python ``` +## Getting Started + +Initialize the Authsignal client with your secret key from the [Authsignal Portal](https://portal.authsignal.com/) and the API URL for your region. + +```python +from authsignal import Authsignal + +# Initialize the client +authsignal = Authsignal( + api_secret_key="your_secret_key", + api_url="https://api.authsignal.com/v1" # Use region-specific URL +) +``` + +### API URLs by Region + +| Region | API URL | +| ----------- | -------------------------------- | +| US (Oregon) | https://api.authsignal.com/v1 | +| AU (Sydney) | https://au.api.authsignal.com/v1 | +| EU (Dublin) | https://eu.api.authsignal.com/v1 | + +## License + +This SDK is licensed under the [MIT License](LICENSE). + ## Documentation -Refer to our [SDK documentation](https://docs.authsignal.com/sdks/server) for information on how to use this SDK. +For more information and advanced usage examples, refer to the official [Authsignal Server-Side SDK documentation](https://docs.authsignal.com/sdks/server/overview). From ddee35a98a39d3b7e003c907be7b238ea58f6763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAshutosh-Bhadauriya=E2=80=9D?= Date: Mon, 21 Apr 2025 08:51:16 +0530 Subject: [PATCH 2/4] add pr template --- .github/pull_request_template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..93a9f6f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +### Breaking Changes + + +### New Features + + +### Bug Fixes + + +### Checklist +- [ ] Version bumped +- [ ] Documentation updated \ No newline at end of file From bc618024fc78d6f0c5d06ae6a6aab70527b12c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAshutosh-Bhadauriya=E2=80=9D?= Date: Tue, 22 Apr 2025 09:45:43 +0530 Subject: [PATCH 3/4] wip --- authsignal/client.py | 4 +++- authsignal/webhook_tests.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/authsignal/client.py b/authsignal/client.py index 4c1571d..553b77e 100644 --- a/authsignal/client.py +++ b/authsignal/client.py @@ -9,6 +9,7 @@ from requests.adapters import HTTPAdapter from authsignal.version import VERSION +from authsignal.webhook import Webhook API_BASE_URL = "https://api.authsignal.com/v1" @@ -98,7 +99,7 @@ class AuthsignalClient(object): def __init__(self, api_secret_key, api_url=API_BASE_URL, timeout=2.0): """Initialize the client. Args: - api_key: Your Authsignal Secret API key of your tenant + api_secret_key: Your Authsignal Secret API key of your tenant api_url: Base URL, including scheme and host, for sending events. Defaults to 'https://api.authsignal.com/v1'. 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): self.session = CustomSession(timeout=timeout, api_key=api_secret_key) self.version = VERSION + self.webhook = Webhook(api_secret_key=api_secret_key) def track( self, user_id: str, action: str, attributes: Dict[str, Any] = None diff --git a/authsignal/webhook_tests.py b/authsignal/webhook_tests.py index 6223507..1e84877 100644 --- a/authsignal/webhook_tests.py +++ b/authsignal/webhook_tests.py @@ -19,7 +19,7 @@ def setUp(self): "tenantId": "7752d28e-e627-4b1b-bb81-b45d68d617bc", "type": "email.created", "data": { - "to": "chris@authsignal.com", + "to": "not-a-real-email@authsignal.com", "code": "157743", "userId": "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0", "actionCode": "accountRecovery", From 9d5cc90e802b47c98462257438dd510765234cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAshutosh-Bhadauriya=E2=80=9D?= Date: Tue, 22 Apr 2025 10:17:02 +0530 Subject: [PATCH 4/4] wip --- authsignal/webhook_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authsignal/webhook_tests.py b/authsignal/webhook_tests.py index 1e84877..715cc4f 100644 --- a/authsignal/webhook_tests.py +++ b/authsignal/webhook_tests.py @@ -9,7 +9,7 @@ class TestWebhook(unittest.TestCase): def setUp(self): - self.secret = "YOUR_AUTHISGNAL_SECRET_KEY" + self.secret = "YOUR_AUTHSIGNAL_SECRET_KEY" self.webhook = Webhook(self.secret) self.payload_valid_signature = json.dumps({ "version": 1, @@ -35,7 +35,7 @@ def setUp(self): "tenantId": "7752d28e-e627-4b1b-bb81-b45d68d617bc", "type": "email.created", "data": { - "to": "chris@authsignal.com", + "to": "not-a-real-email@authsignal.com", "code": "718190", "userId": "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0", "actionCode": "accountRecovery",