Skip to content

Commit 10d54ef

Browse files
committed
Add WhatsApp echo webhook
1 parent 4d7665a commit 10d54ef

File tree

3 files changed

+122
-10
lines changed

3 files changed

+122
-10
lines changed

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

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)

0 commit comments

Comments
 (0)