Coverage for server / routes / common / auth0_webhook.py: 96%
127 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2Auth0 Webhook Endpoint (Teacher-Student API)
4Receives user lifecycle events from Auth0 Actions (post-user-registration)
5and syncs them to the User collection in MongoDB.
7Authentication (SEC-2)
8----------------------
9Two layers, the second phased in:
111. `x-auth0-webhook-secret` — a shared secret, compared in CONSTANT TIME.
12 A plain `!=` leaks the position of the first differing byte through
13 timing, which is the kind of oracle that turns an unguessable secret
14 into a guessable one.
162. `x-auth0-webhook-signature` — HMAC-SHA256 over the raw request body,
17 bound to a timestamp. Enforced only when AUTH0_WEBHOOK_SIGNING_SECRET
18 is configured.
20Why the shared secret alone is not enough: it is a bearer credential that
21travels on every request and authenticates the CALLER but says nothing
22about the BODY. Anyone who ever observes it — a log, a proxy, a mis-set
23header on another host — can forge arbitrary user.created / user.deleted
24events, and can replay a captured request indefinitely. The signature
25binds the payload and the timestamp bounds the replay window.
27The phasing is deliberate. This endpoint is live, and the signing half
28lives in an Auth0 Action, not in this repo. Setting the env var before
29the Action is updated would break user-lifecycle sync, so signature
30enforcement activates only once AUTH0_WEBHOOK_SIGNING_SECRET is set:
31update the Action first, then set the variable.
33The Auth0 Action must send:
35 t = Math.floor(Date.now() / 1000)
36 sig = HMAC_SHA256(secret, `${t}.${rawBody}`) // hex
37 headers['x-auth0-webhook-signature'] = `t=${t},v1=${sig}`
39`rawBody` must be the exact bytes sent, not a re-serialised object.
41Events:
42 - user.created: Upsert User in MongoDB
43 - user.updated: Update User fields by auth0_user_id
44 - user.deleted: Soft-delete User (status="deleted")
45"""
47import hashlib
48import hmac
49import json
50import logging
51import os
52import time
53from datetime import datetime, timezone
55from fastapi import APIRouter, HTTPException, Request, status
56from dotenv import load_dotenv
58load_dotenv()
60logger = logging.getLogger(__name__)
62router = APIRouter()
64AUTH0_WEBHOOK_SECRET = os.getenv("AUTH0_WEBHOOK_SECRET", "")
66#: When set, a valid `x-auth0-webhook-signature` becomes MANDATORY. Left unset
67#: until the Auth0 Action is updated to send one — see the module docstring.
68AUTH0_WEBHOOK_SIGNING_SECRET = os.getenv("AUTH0_WEBHOOK_SIGNING_SECRET", "")
70#: How far a signature timestamp may drift, in seconds. This is the replay
71#: window: a captured request is reusable only until it expires.
72AUTH0_WEBHOOK_TOLERANCE_SEC = int(os.getenv("AUTH0_WEBHOOK_TOLERANCE_SEC", "300"))
74_SIGNATURE_HEADER = "x-auth0-webhook-signature"
77def _unauthorized(detail: str) -> HTTPException:
78 """401 with a deliberately coarse reason.
80 The caller is an Auth0 Action, not a human debugging in the browser, so
81 there is nothing to gain from telling an attacker WHICH check failed.
82 """
83 logger.warning("Auth0 webhook rejected: %s", detail)
84 return HTTPException(
85 status_code=status.HTTP_401_UNAUTHORIZED,
86 detail="Invalid webhook credentials",
87 )
90def _parse_signature_header(raw: str) -> tuple[int, str] | None:
91 """Parse `t=<unix>,v1=<hex>` into (timestamp, hex_digest), or None."""
92 ts: int | None = None
93 sig: str | None = None
94 for part in raw.split(","):
95 key, _, value = part.strip().partition("=")
96 if key == "t":
97 try:
98 ts = int(value)
99 except ValueError:
100 return None
101 elif key == "v1":
102 sig = value
103 if ts is None or not sig:
104 return None
105 return ts, sig
108def _verify_signature(raw_body: bytes, header: str) -> None:
109 """Verify the HMAC signature over `<t>.<raw_body>`, or raise 401."""
110 parsed = _parse_signature_header(header)
111 if not parsed:
112 raise _unauthorized("malformed signature header")
113 timestamp, provided = parsed
115 # Replay window. Checked BEFORE the HMAC so a stale-but-validly-signed
116 # replay is rejected on its own merits.
117 if abs(time.time() - timestamp) > AUTH0_WEBHOOK_TOLERANCE_SEC:
118 raise _unauthorized("signature timestamp outside tolerance")
120 expected = hmac.new(
121 AUTH0_WEBHOOK_SIGNING_SECRET.encode(),
122 f"{timestamp}.".encode() + raw_body,
123 hashlib.sha256,
124 ).hexdigest()
126 if not hmac.compare_digest(expected, provided):
127 raise _unauthorized("signature mismatch")
130def _authenticate_webhook(request: Request, raw_body: bytes) -> None:
131 """Authenticate the request. Raises 401/500; returns None on success."""
132 if not AUTH0_WEBHOOK_SECRET:
133 raise HTTPException(
134 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
135 detail="Webhook secret not configured",
136 )
138 # Constant-time: `!=` on a secret leaks the first differing byte position.
139 provided = request.headers.get("x-auth0-webhook-secret", "")
140 if not hmac.compare_digest(provided, AUTH0_WEBHOOK_SECRET):
141 raise _unauthorized("shared secret mismatch")
143 if AUTH0_WEBHOOK_SIGNING_SECRET:
144 signature = request.headers.get(_SIGNATURE_HEADER, "")
145 if not signature:
146 raise _unauthorized("signature required but not supplied")
147 _verify_signature(raw_body, signature)
150def _validate_webhook_secret(request: Request) -> None:
151 """Deprecated shim kept for callers/tests that predate SEC-2.
153 Performs the shared-secret half only; it cannot check a signature because
154 it has no access to the raw body. Prefer `_authenticate_webhook`.
155 """
156 _authenticate_webhook(request, b"")
159@router.post(
160 "/auth0/user-sync",
161 summary="Auth0 user-lifecycle webhook",
162 description=(
163 "Invoked by Auth0 post-user-registration / post-user-update / "
164 "post-user-deletion actions. Validates the `x-auth0-webhook-secret` "
165 "header against `AUTH0_WEBHOOK_SECRET` and upserts/soft-deletes the "
166 "corresponding User profile in MongoDB. Not called directly by clients."
167 ),
168 responses={
169 200: {"description": "Event processed"},
170 400: {"description": "Invalid JSON payload or missing required fields"},
171 401: {"description": "Invalid credentials — bad shared secret, or (when signing is enabled) a missing/invalid/expired `x-auth0-webhook-signature`"},
172 500: {"description": "Server error (webhook secret not configured, or DB error)"},
173 },
174)
175async def auth0_user_sync(request: Request):
176 """
177 Receive Auth0 user lifecycle events and sync to MongoDB User collection.
179 Expected payload:
180 {
181 "event": "user.created" | "user.updated" | "user.deleted",
182 "user": {
183 "user_id": "auth0|abc123",
184 "email": "user@example.com",
185 "name": "John Doe",
186 "given_name": "John",
187 "family_name": "Doe",
188 "app_metadata": { "role": "teacher" }
189 }
190 }
191 """
192 # Read the raw bytes once: the signature covers exactly what was sent, so
193 # re-serialising a parsed object would not reproduce the signed payload.
194 raw_body = await request.body()
195 _authenticate_webhook(request, raw_body)
197 try:
198 body = json.loads(raw_body)
199 if not isinstance(body, dict):
200 raise ValueError("payload must be a JSON object")
201 except Exception:
202 raise HTTPException(
203 status_code=status.HTTP_400_BAD_REQUEST,
204 detail="Invalid JSON payload",
205 )
207 event = body.get("event")
208 user_data = body.get("user", {})
210 if not event or not user_data.get("user_id"):
211 raise HTTPException(
212 status_code=status.HTTP_400_BAD_REQUEST,
213 detail="Missing required fields: event, user.user_id",
214 )
216 auth0_user_id = user_data["user_id"]
217 email = user_data.get("email", "")
218 name = user_data.get("name", "")
219 given_name = user_data.get("given_name", "")
220 family_name = user_data.get("family_name", "")
221 role = user_data.get("app_metadata", {}).get("role", "teacher")
223 # Only handle teacher/student roles on this API
224 if role not in ("teacher", "student"):
225 return {"status": "ok", "action": "skipped", "reason": f"role '{role}' not handled by this API"}
227 from server.models.users import User
229 if event == "user.created":
230 # Check if user already exists (by auth0_user_id or email)
231 existing = await User.find_one({"auth0_user_id": auth0_user_id})
232 if not existing and email:
233 existing = await User.find_one({"email": email})
235 if existing:
236 # Link existing user to Auth0
237 existing.auth0_user_id = auth0_user_id
238 existing.auth_provider = "auth0"
239 if given_name:
240 existing.first_name = given_name
241 if family_name:
242 existing.last_name = family_name
243 await existing.save()
244 logger.info(f"Linked existing user to Auth0 user {auth0_user_id}")
245 else:
246 # Create new user
247 first_name = given_name or (name.split(" ", 1)[0] if name else "User")
248 last_name = family_name or (name.split(" ", 1)[-1] if name and " " in name else first_name)
250 new_user = User(
251 first_name=first_name,
252 last_name=last_name,
253 email=email or f"{auth0_user_id.replace('|', '_')}@auth0.placeholder",
254 role=role,
255 status="active",
256 auth0_user_id=auth0_user_id,
257 auth_provider="auth0",
258 )
259 await new_user.insert()
260 logger.info(f"Created user from Auth0 webhook for user {auth0_user_id}")
262 return {"status": "ok", "action": "created_or_linked"}
264 elif event == "user.updated":
265 user = await User.find_one({"auth0_user_id": auth0_user_id})
266 if not user:
267 logger.warning(f"No user found for Auth0 user {auth0_user_id} during update webhook")
268 return {"status": "ok", "action": "no_user_found"}
270 updates = {}
271 if given_name:
272 updates["first_name"] = given_name
273 if family_name:
274 updates["last_name"] = family_name
275 if email:
276 updates["email"] = email
277 if role:
278 updates["role"] = role
279 if updates:
280 updates["updated_at"] = datetime.now(timezone.utc)
281 await user.update({"$set": updates})
282 logger.info(f"Updated user for Auth0 user {auth0_user_id}: {list(updates.keys())}")
284 return {"status": "ok", "action": "updated"}
286 elif event == "user.deleted":
287 user = await User.find_one({"auth0_user_id": auth0_user_id})
288 if not user:
289 logger.warning(f"No user found for Auth0 user {auth0_user_id} during delete webhook")
290 return {"status": "ok", "action": "no_user_found"}
292 await user.update({
293 "$set": {
294 "status": "deleted",
295 "updated_at": datetime.now(timezone.utc),
296 }
297 })
298 logger.info(f"Soft-deleted user for Auth0 user {auth0_user_id}")
299 return {"status": "ok", "action": "deleted"}
301 else:
302 raise HTTPException(
303 status_code=status.HTTP_400_BAD_REQUEST,
304 detail=f"Unknown event type: {event}",
305 )