Coverage for server / services / common / auth0_management.py: 86%
98 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 Management API Client (Teacher-Student API)
4Provides async methods for creating, updating, and deleting users in Auth0
5via the Management API. Used when students are created via the local fallback
6path (when Express bulk-upload is unavailable) so they also exist in Auth0
7and can log in.
9Uses M2M (client_credentials) tokens with caching.
10"""
12import logging
13import os
14import time
15from typing import Optional
17import httpx
18from dotenv import load_dotenv
20load_dotenv()
22logger = logging.getLogger(__name__)
24# Auth0 Management API configuration
25AUTH0_DOMAIN = os.getenv("AUTH0_DOMAIN", "")
26AUTH0_MGMT_CLIENT_ID = os.getenv("AUTH0_MGMT_CLIENT_ID", "")
27AUTH0_MGMT_CLIENT_SECRET = os.getenv("AUTH0_MGMT_CLIENT_SECRET", "")
28AUTH0_MGMT_AUDIENCE = os.getenv(
29 "AUTH0_MGMT_AUDIENCE",
30 f"https://{AUTH0_DOMAIN}/api/v2/" if AUTH0_DOMAIN else "",
31)
33# Role ID mappings
34AUTH0_ROLE_IDS = {
35 "admin": os.getenv("AUTH0_ROLE_ID_ADMIN", ""),
36 "staff": os.getenv("AUTH0_ROLE_ID_STAFF", ""),
37 "teacher": os.getenv("AUTH0_ROLE_ID_TEACHER", ""),
38 "student": os.getenv("AUTH0_ROLE_ID_STUDENT", ""),
39}
42class Auth0ManagementService:
43 """Auth0 Management API client for user lifecycle operations."""
45 def __init__(self):
46 self._cached_token: Optional[str] = None
47 self._token_expires_at: float = 0
48 self._last_call_time: float = 0
50 def _is_configured(self) -> bool:
51 """Check if Auth0 Management API credentials are configured."""
52 return bool(AUTH0_DOMAIN and AUTH0_MGMT_CLIENT_ID and AUTH0_MGMT_CLIENT_SECRET)
54 async def _rate_limit(self):
55 """Enforce minimum 100ms between API calls to avoid Auth0 rate limits."""
56 now = time.monotonic()
57 elapsed = now - self._last_call_time
58 if elapsed < 0.1:
59 import asyncio
60 await asyncio.sleep(0.1 - elapsed)
61 self._last_call_time = time.monotonic()
63 async def get_management_token(self) -> str:
64 """
65 Get a cached M2M token for the Auth0 Management API.
67 Token is cached for 58 minutes (Auth0 tokens expire after 24h by default,
68 but we refresh conservatively).
69 """
70 now = time.time()
71 if self._cached_token and now < self._token_expires_at:
72 return self._cached_token
74 async with httpx.AsyncClient(timeout=10.0) as client:
75 response = await client.post(
76 f"https://{AUTH0_DOMAIN}/oauth/token",
77 json={
78 "client_id": AUTH0_MGMT_CLIENT_ID,
79 "client_secret": AUTH0_MGMT_CLIENT_SECRET,
80 "audience": AUTH0_MGMT_AUDIENCE,
81 "grant_type": "client_credentials",
82 },
83 )
84 response.raise_for_status()
85 data = response.json()
87 self._cached_token = data["access_token"]
88 # Cache for 58 minutes (or use expires_in if shorter)
89 expires_in = min(data.get("expires_in", 3480), 3480)
90 self._token_expires_at = now + expires_in
91 return self._cached_token
93 async def create_user(
94 self,
95 email: str,
96 password: str,
97 role: str,
98 given_name: str = "",
99 family_name: str = "",
100 ) -> dict:
101 """
102 Create a user in Auth0 and assign their role.
104 Args:
105 email: User email address.
106 password: Initial password for the user.
107 role: Role to assign (teacher, student).
108 given_name: User's first name.
109 family_name: User's last name.
111 Returns:
112 dict with user_id, email, and other Auth0 user fields.
114 Raises:
115 httpx.HTTPStatusError: On Auth0 API errors.
116 """
117 if not self._is_configured():
118 raise RuntimeError("Auth0 Management API not configured. Set AUTH0_DOMAIN, AUTH0_MGMT_CLIENT_ID, AUTH0_MGMT_CLIENT_SECRET.")
120 token = await self.get_management_token()
121 headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
123 # Create the user
124 await self._rate_limit()
125 async with httpx.AsyncClient(timeout=10.0) as client:
126 create_response = await client.post(
127 f"https://{AUTH0_DOMAIN}/api/v2/users",
128 headers=headers,
129 json={
130 "email": email,
131 "password": password,
132 "connection": "Username-Password-Authentication",
133 "given_name": given_name or "User",
134 "family_name": family_name or "User",
135 "name": f"{given_name} {family_name}".strip() or email,
136 "app_metadata": {"role": role},
137 },
138 )
139 if create_response.status_code == 429:
140 logger.warning("Auth0 rate limit hit during user creation")
141 raise httpx.HTTPStatusError(
142 "Rate limited", request=create_response.request, response=create_response
143 )
144 create_response.raise_for_status()
145 user_data = create_response.json()
147 auth0_user_id = user_data["user_id"]
149 # Assign role if role ID is configured
150 role_id = AUTH0_ROLE_IDS.get(role)
151 if role_id:
152 await self._rate_limit()
153 async with httpx.AsyncClient(timeout=10.0) as client:
154 role_response = await client.post(
155 f"https://{AUTH0_DOMAIN}/api/v2/users/{auth0_user_id}/roles",
156 headers=headers,
157 json={"roles": [role_id]},
158 )
159 if role_response.status_code != 204:
160 logger.warning(
161 f"Failed to assign role '{role}' to Auth0 user {auth0_user_id}: "
162 f"{role_response.status_code} {role_response.text}"
163 )
165 logger.info(f"Created Auth0 user {auth0_user_id} with role '{role}'")
166 return user_data
168 async def update_user(self, auth0_user_id: str, **fields) -> dict:
169 """
170 Update a user's profile in Auth0.
172 Accepts keyword arguments matching Auth0 user fields:
173 email, given_name, family_name, name, blocked, etc.
175 Returns the updated user object from Auth0.
176 """
177 if not self._is_configured():
178 raise RuntimeError("Auth0 Management API not configured.")
180 token = await self.get_management_token()
181 headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
183 await self._rate_limit()
184 async with httpx.AsyncClient(timeout=10.0) as client:
185 response = await client.patch(
186 f"https://{AUTH0_DOMAIN}/api/v2/users/{auth0_user_id}",
187 headers=headers,
188 json=fields,
189 )
190 response.raise_for_status()
191 return response.json()
193 async def delete_user(self, auth0_user_id: str) -> None:
194 """Delete a user from Auth0."""
195 if not self._is_configured():
196 raise RuntimeError("Auth0 Management API not configured.")
198 token = await self.get_management_token()
199 headers = {"Authorization": f"Bearer {token}"}
201 await self._rate_limit()
202 async with httpx.AsyncClient(timeout=10.0) as client:
203 response = await client.delete(
204 f"https://{AUTH0_DOMAIN}/api/v2/users/{auth0_user_id}",
205 headers=headers,
206 )
207 response.raise_for_status()
209 logger.info(f"Deleted Auth0 user {auth0_user_id}")
211 async def generate_password_change_ticket(
212 self,
213 auth0_user_id: str,
214 result_url: str,
215 ttl_sec: int = 432000, # 5 days
216 mark_email_as_verified: bool = False,
217 ) -> dict:
218 """
219 Generate an Auth0 password-change ticket for a user.
221 The returned ticket URL sends the user to Auth0's hosted page where
222 they can set a new password without this backend ever touching the
223 plaintext. This is the Auth0-recommended pattern and mirrors the
224 invitation-email flow in the bulk-upload service.
226 Args:
227 auth0_user_id: The Auth0 user id (e.g., "auth0|abc123").
228 result_url: Where Auth0 redirects after success (typically /login).
229 ttl_sec: Ticket validity in seconds. Default 5 days.
230 mark_email_as_verified: If True, Auth0 flips email_verified when
231 the ticket is *generated*. Default False.
233 Returns:
234 dict with keys ``ticket`` (the opaque URL the user must open)
235 and ``expires_at`` (ISO-8601 timestamp, computed locally).
236 """
237 if not self._is_configured():
238 raise RuntimeError("Auth0 Management API not configured.")
240 token = await self.get_management_token()
241 headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
243 payload = {
244 "user_id": auth0_user_id,
245 "result_url": result_url,
246 "ttl_sec": ttl_sec,
247 "mark_email_as_verified": mark_email_as_verified,
248 }
250 await self._rate_limit()
251 async with httpx.AsyncClient(timeout=10.0) as client:
252 response = await client.post(
253 f"https://{AUTH0_DOMAIN}/api/v2/tickets/password-change",
254 headers=headers,
255 json=payload,
256 )
257 response.raise_for_status()
258 data = response.json()
260 from datetime import datetime, timedelta, timezone
261 expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_sec)).isoformat()
263 logger.info(
264 f"Generated password-change ticket for Auth0 user {auth0_user_id} "
265 f"(expires {expires_at})"
266 )
267 return {"ticket": data.get("ticket"), "expires_at": expires_at}
270# Module-level singleton
271auth0_management = Auth0ManagementService()