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

1""" 

2Auth0 Webhook Endpoint (Teacher-Student API) 

3 

4Receives user lifecycle events from Auth0 Actions (post-user-registration) 

5and syncs them to the User collection in MongoDB. 

6 

7Authentication (SEC-2) 

8---------------------- 

9Two layers, the second phased in: 

10 

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. 

15 

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. 

19 

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. 

26 

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. 

32 

33The Auth0 Action must send: 

34 

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}` 

38 

39`rawBody` must be the exact bytes sent, not a re-serialised object. 

40 

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""" 

46 

47import hashlib 

48import hmac 

49import json 

50import logging 

51import os 

52import time 

53from datetime import datetime, timezone 

54 

55from fastapi import APIRouter, HTTPException, Request, status 

56from dotenv import load_dotenv 

57 

58load_dotenv() 

59 

60logger = logging.getLogger(__name__) 

61 

62router = APIRouter() 

63 

64AUTH0_WEBHOOK_SECRET = os.getenv("AUTH0_WEBHOOK_SECRET", "") 

65 

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", "") 

69 

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")) 

73 

74_SIGNATURE_HEADER = "x-auth0-webhook-signature" 

75 

76 

77def _unauthorized(detail: str) -> HTTPException: 

78 """401 with a deliberately coarse reason. 

79 

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 ) 

88 

89 

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 

106 

107 

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 

114 

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") 

119 

120 expected = hmac.new( 

121 AUTH0_WEBHOOK_SIGNING_SECRET.encode(), 

122 f"{timestamp}.".encode() + raw_body, 

123 hashlib.sha256, 

124 ).hexdigest() 

125 

126 if not hmac.compare_digest(expected, provided): 

127 raise _unauthorized("signature mismatch") 

128 

129 

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 ) 

137 

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") 

142 

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) 

148 

149 

150def _validate_webhook_secret(request: Request) -> None: 

151 """Deprecated shim kept for callers/tests that predate SEC-2. 

152 

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"") 

157 

158 

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. 

178 

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) 

196 

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 ) 

206 

207 event = body.get("event") 

208 user_data = body.get("user", {}) 

209 

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 ) 

215 

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") 

222 

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"} 

226 

227 from server.models.users import User 

228 

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}) 

234 

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) 

249 

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}") 

261 

262 return {"status": "ok", "action": "created_or_linked"} 

263 

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"} 

269 

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())}") 

283 

284 return {"status": "ok", "action": "updated"} 

285 

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"} 

291 

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"} 

300 

301 else: 

302 raise HTTPException( 

303 status_code=status.HTTP_400_BAD_REQUEST, 

304 detail=f"Unknown event type: {event}", 

305 )