Skip to content

Commit 063f8a5

Browse files
authored
Release Candidate v3.10.1 (#197)
2 parents b4dd8e2 + 8f3cf90 commit 063f8a5

File tree

6 files changed

+130
-11
lines changed

6 files changed

+130
-11
lines changed

.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.10.0
1+
3.10.1

src/api/auth.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import hashlib
2+
import hmac
13
from datetime import datetime, timedelta, timezone
24
from typing import Any, Dict
35

@@ -13,7 +15,6 @@
1315

1416
api_key_header = APIKeyHeader(name = "X-API-Key", auto_error = True)
1517
telegram_auth_key_header = APIKeyHeader(name = "X-Telegram-Bot-Api-Secret-Token", auto_error = False)
16-
whatsapp_auth_key_header = APIKeyHeader(name = "X-WhatsApp-Bot-Api-Secret-Token", auto_error = False)
1718
jwt_header = HTTPBearer(bearerFormat = "JWT", auto_error = True)
1819

1920

@@ -29,10 +30,35 @@ def verify_telegram_auth_key(auth_key: str = Security(telegram_auth_key_header))
2930
return auth_key
3031

3132

32-
def verify_whatsapp_auth_key(auth_key: str = Security(whatsapp_auth_key_header)) -> str:
33-
if config.whatsapp_must_auth and auth_key != config.whatsapp_auth_key.get_secret_value():
34-
raise HTTPException(status_code = HTTP_403_FORBIDDEN, detail = "Could not validate the WhatsApp auth token")
35-
return auth_key
33+
def verify_whatsapp_webhook_challenge(mode: str, challenge: str, verify_token: str) -> str:
34+
if not config.whatsapp_must_auth:
35+
log.i("WhatsApp webhook verification skipped (auth disabled)")
36+
return challenge
37+
token_valid = verify_token == config.whatsapp_auth_key.get_secret_value()
38+
if mode == "subscribe" and token_valid:
39+
log.i("WhatsApp webhook verified successfully")
40+
return challenge
41+
else:
42+
log.w(f"WhatsApp webhook verification failed: mode={mode}, token_match={token_valid}")
43+
raise HTTPException(status_code = HTTP_403_FORBIDDEN, detail = "Webhook verification failed")
44+
45+
46+
def verify_whatsapp_signature(payload: bytes, signature_header: str | None) -> None:
47+
if not config.whatsapp_must_auth:
48+
log.i("WhatsApp signature verification skipped (auth disabled)")
49+
return
50+
if not signature_header:
51+
log.w("WhatsApp signature verification failed: missing X-Hub-Signature-256 header")
52+
raise HTTPException(status_code = HTTP_403_FORBIDDEN, detail = "Missing signature header")
53+
if not signature_header.startswith("sha256="):
54+
log.w(f"WhatsApp signature verification failed: invalid header format: {signature_header}")
55+
raise HTTPException(status_code = HTTP_403_FORBIDDEN, detail = "Invalid signature format")
56+
received_signature = signature_header[7:]
57+
expected_signature = hmac.new(config.whatsapp_app_secret.get_secret_value().encode(), payload, hashlib.sha256).hexdigest()
58+
if not hmac.compare_digest(expected_signature, received_signature):
59+
log.w("WhatsApp signature verification failed: signature mismatch")
60+
raise HTTPException(status_code = HTTP_403_FORBIDDEN, detail = "Invalid signature")
61+
log.i("WhatsApp signature verified successfully")
3662

3763

3864
def verify_jwt_credentials(authorization: HTTPAuthorizationCredentials = Security(jwt_header)) -> Dict[str, Any]:

src/main.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from uuid import UUID
1010

1111
import uvicorn
12-
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException
12+
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, Query, Request
1313
from fastapi.middleware.cors import CORSMiddleware
1414
from pydantic import SecretStr
1515
from starlette.responses import RedirectResponse
@@ -20,7 +20,8 @@
2020
verify_api_key,
2121
verify_jwt_credentials,
2222
verify_telegram_auth_key,
23-
verify_whatsapp_auth_key,
23+
verify_whatsapp_signature,
24+
verify_whatsapp_webhook_challenge,
2425
)
2526
from api.model.chat_settings_payload import ChatSettingsPayload
2627
from api.model.release_output_payload import ReleaseOutputPayload
@@ -90,12 +91,24 @@ async def telegram_chat_update(
9091
return {"status": "ok"}
9192

9293

94+
@app.get("/whatsapp/chat-update")
95+
async def whatsapp_webhook_verification(
96+
hub_mode: str = Query(alias = "hub.mode"),
97+
hub_challenge: str = Query(alias = "hub.challenge"),
98+
hub_verify_token: str = Query(alias = "hub.verify_token"),
99+
) -> str:
100+
return verify_whatsapp_webhook_challenge(hub_mode, hub_challenge, hub_verify_token)
101+
102+
93103
@app.post("/whatsapp/chat-update")
94104
async def whatsapp_chat_update(
95-
update: dict,
96-
_ = Depends(verify_whatsapp_auth_key),
105+
request: Request,
106+
x_hub_signature_256: str | None = Header(default = None),
97107
) -> dict:
98-
log.d("Received WhatsApp update", update)
108+
body = await request.body()
109+
verify_whatsapp_signature(body, x_hub_signature_256)
110+
update = await request.json()
111+
log.d(f"Received WhatsApp update: {update}")
99112
return {"status": "ok"}
100113

101114

src/util/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class Config(metaclass = Singleton):
4747
telegram_auth_key: SecretStr
4848
telegram_bot_token: SecretStr
4949
whatsapp_auth_key: SecretStr
50+
whatsapp_app_secret: SecretStr
5051
jwt_secret_key: SecretStr
5152
github_issues_token: SecretStr
5253
rapid_api_twitter_token: SecretStr
@@ -60,6 +61,7 @@ def all_secrets(self) -> list[SecretStr]:
6061
self.telegram_auth_key,
6162
self.telegram_bot_token,
6263
self.whatsapp_auth_key,
64+
self.whatsapp_app_secret,
6365
self.jwt_secret_key,
6466
self.github_issues_token,
6567
self.rapid_api_twitter_token,
@@ -107,6 +109,7 @@ def __init__(
107109
def_telegram_auth_key: SecretStr = SecretStr("it_is_really_telegram"),
108110
def_telegram_bot_token: SecretStr = SecretStr("invalid"),
109111
def_whatsapp_auth_key: SecretStr = SecretStr("it_is_really_whatsapp"),
112+
def_whatsapp_app_secret: SecretStr = SecretStr("invalid"),
110113
def_jwt_secret_key: SecretStr = SecretStr("default"),
111114
def_github_issues_token: SecretStr = SecretStr("invalid"),
112115
def_rapid_api_twitter_token: SecretStr = SecretStr("invalid"),
@@ -149,6 +152,7 @@ def __init__(
149152
self.telegram_auth_key = self.__senv("TELEGRAM_API_UPDATE_AUTH_TOKEN", lambda: def_telegram_auth_key)
150153
self.telegram_bot_token = self.__senv("TELEGRAM_BOT_TOKEN", lambda: def_telegram_bot_token)
151154
self.whatsapp_auth_key = self.__senv("WHATSAPP_API_UPDATE_AUTH_TOKEN", lambda: def_whatsapp_auth_key)
155+
self.whatsapp_app_secret = self.__senv("WHATSAPP_APP_SECRET", lambda: def_whatsapp_app_secret)
152156
self.jwt_secret_key = self.__senv("JWT_SECRET_KEY", lambda: def_jwt_secret_key)
153157
self.github_issues_token = self.__senv("THE_AGENT_ISSUES_TOKEN", lambda: def_github_issues_token)
154158
self.rapid_api_twitter_token = self.__senv("RAPID_API_TWITTER_TOKEN", lambda: def_rapid_api_twitter_token)

test/api/test_auth.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
verify_api_key,
1515
verify_jwt_credentials,
1616
verify_telegram_auth_key,
17+
verify_whatsapp_signature,
18+
verify_whatsapp_webhook_challenge,
1719
)
1820
from util.config import config
1921

@@ -138,3 +140,74 @@ def test_get_chat_type_from_jwt_missing_platform(self):
138140
claims = {"sub": "user-123"}
139141
chat_type = get_chat_type_from_jwt(claims)
140142
self.assertIsNone(chat_type)
143+
144+
@patch("api.auth.config")
145+
def test_whatsapp_webhook_challenge_success(self, mock_config: MagicMock):
146+
mock_config.whatsapp_must_auth = True
147+
mock_config.whatsapp_auth_key = SecretStr("test-token")
148+
challenge = verify_whatsapp_webhook_challenge("subscribe", "test-challenge", "test-token")
149+
self.assertEqual(challenge, "test-challenge")
150+
151+
@patch("api.auth.config")
152+
def test_whatsapp_webhook_challenge_invalid_token(self, mock_config: MagicMock):
153+
mock_config.whatsapp_must_auth = True
154+
mock_config.whatsapp_auth_key = SecretStr("correct-token")
155+
with self.assertRaises(HTTPException) as context:
156+
verify_whatsapp_webhook_challenge("subscribe", "test-challenge", "wrong-token")
157+
self.assertEqual(context.exception.status_code, HTTP_403_FORBIDDEN)
158+
self.assertEqual(context.exception.detail, "Webhook verification failed")
159+
160+
@patch("api.auth.config")
161+
def test_whatsapp_webhook_challenge_invalid_mode(self, mock_config: MagicMock):
162+
mock_config.whatsapp_must_auth = True
163+
mock_config.whatsapp_auth_key = SecretStr("test-token")
164+
with self.assertRaises(HTTPException) as context:
165+
verify_whatsapp_webhook_challenge("unsubscribe", "test-challenge", "test-token")
166+
self.assertEqual(context.exception.status_code, HTTP_403_FORBIDDEN)
167+
self.assertEqual(context.exception.detail, "Webhook verification failed")
168+
169+
def test_whatsapp_webhook_challenge_auth_disabled(self):
170+
challenge = verify_whatsapp_webhook_challenge("subscribe", "test-challenge", "any-token")
171+
self.assertEqual(challenge, "test-challenge")
172+
173+
@patch("api.auth.config")
174+
def test_whatsapp_signature_verification_success(self, mock_config: MagicMock):
175+
import hashlib
176+
import hmac
177+
mock_config.whatsapp_must_auth = True
178+
mock_config.whatsapp_app_secret = SecretStr("test-secret")
179+
payload = b'{"test": "data"}'
180+
signature = hmac.new(b"test-secret", payload, hashlib.sha256).hexdigest()
181+
verify_whatsapp_signature(payload, f"sha256={signature}")
182+
183+
@patch("api.auth.config")
184+
def test_whatsapp_signature_verification_invalid_signature(self, mock_config: MagicMock):
185+
mock_config.whatsapp_must_auth = True
186+
mock_config.whatsapp_app_secret = SecretStr("test-secret")
187+
payload = b'{"test": "data"}'
188+
with self.assertRaises(HTTPException) as context:
189+
verify_whatsapp_signature(payload, "sha256=wrong-signature")
190+
self.assertEqual(context.exception.status_code, HTTP_403_FORBIDDEN)
191+
self.assertEqual(context.exception.detail, "Invalid signature")
192+
193+
@patch("api.auth.config")
194+
def test_whatsapp_signature_verification_missing_header(self, mock_config: MagicMock):
195+
mock_config.whatsapp_must_auth = True
196+
payload = b'{"test": "data"}'
197+
with self.assertRaises(HTTPException) as context:
198+
verify_whatsapp_signature(payload, None)
199+
self.assertEqual(context.exception.status_code, HTTP_403_FORBIDDEN)
200+
self.assertEqual(context.exception.detail, "Missing signature header")
201+
202+
@patch("api.auth.config")
203+
def test_whatsapp_signature_verification_invalid_format(self, mock_config: MagicMock):
204+
mock_config.whatsapp_must_auth = True
205+
payload = b'{"test": "data"}'
206+
with self.assertRaises(HTTPException) as context:
207+
verify_whatsapp_signature(payload, "invalid-format")
208+
self.assertEqual(context.exception.status_code, HTTP_403_FORBIDDEN)
209+
self.assertEqual(context.exception.detail, "Invalid signature format")
210+
211+
def test_whatsapp_signature_verification_auth_disabled(self):
212+
payload = b'{"test": "data"}'
213+
verify_whatsapp_signature(payload, None)

test/util/test_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def test_default_config(self):
5757
self.assertEqual(config.telegram_auth_key.get_secret_value(), "it_is_really_telegram")
5858
self.assertEqual(config.telegram_bot_token.get_secret_value(), "invalid")
5959
self.assertEqual(config.whatsapp_auth_key.get_secret_value(), "it_is_really_whatsapp")
60+
self.assertEqual(config.whatsapp_app_secret.get_secret_value(), "invalid")
6061
self.assertEqual(config.jwt_secret_key.get_secret_value(), "default")
6162
self.assertEqual(config.github_issues_token.get_secret_value(), "invalid")
6263
self.assertEqual(config.rapid_api_twitter_token.get_secret_value(), "invalid")
@@ -102,6 +103,7 @@ def test_custom_config(self):
102103
os.environ["TELEGRAM_API_UPDATE_AUTH_TOKEN"] = "abcd1234"
103104
os.environ["TELEGRAM_BOT_TOKEN"] = "id:sha"
104105
os.environ["WHATSAPP_API_UPDATE_AUTH_TOKEN"] = "efgh5678"
106+
os.environ["WHATSAPP_APP_SECRET"] = "ijkl9012"
105107
os.environ["JWT_SECRET_KEY"] = "custom"
106108
os.environ["THE_AGENT_ISSUES_TOKEN"] = "sk-gi-valid"
107109
os.environ["RAPID_API_TWITTER_TOKEN"] = "sk-rt-valid"
@@ -145,6 +147,7 @@ def test_custom_config(self):
145147
self.assertEqual(config.telegram_auth_key.get_secret_value(), "abcd1234")
146148
self.assertEqual(config.telegram_bot_token.get_secret_value(), "id:sha")
147149
self.assertEqual(config.whatsapp_auth_key.get_secret_value(), "efgh5678")
150+
self.assertEqual(config.whatsapp_app_secret.get_secret_value(), "ijkl9012")
148151
self.assertEqual(config.jwt_secret_key.get_secret_value(), "custom")
149152
self.assertEqual(config.github_issues_token.get_secret_value(), "sk-gi-valid")
150153
self.assertEqual(config.rapid_api_twitter_token.get_secret_value(), "sk-rt-valid")

0 commit comments

Comments
 (0)