arize-phoenix 12.2.0__py3-none-any.whl → 12.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arize-phoenix might be problematic. Click here for more details.

Files changed (46) hide show
  1. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/METADATA +2 -1
  2. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/RECORD +45 -44
  3. phoenix/auth.py +19 -0
  4. phoenix/config.py +302 -53
  5. phoenix/db/README.md +546 -28
  6. phoenix/server/api/auth_messages.py +46 -0
  7. phoenix/server/api/routers/auth.py +21 -30
  8. phoenix/server/api/routers/oauth2.py +255 -46
  9. phoenix/server/api/routers/v1/__init__.py +2 -3
  10. phoenix/server/api/routers/v1/annotation_configs.py +12 -29
  11. phoenix/server/api/routers/v1/annotations.py +21 -22
  12. phoenix/server/api/routers/v1/datasets.py +38 -56
  13. phoenix/server/api/routers/v1/documents.py +2 -3
  14. phoenix/server/api/routers/v1/evaluations.py +12 -24
  15. phoenix/server/api/routers/v1/experiment_evaluations.py +2 -3
  16. phoenix/server/api/routers/v1/experiment_runs.py +9 -10
  17. phoenix/server/api/routers/v1/experiments.py +16 -17
  18. phoenix/server/api/routers/v1/projects.py +15 -21
  19. phoenix/server/api/routers/v1/prompts.py +30 -31
  20. phoenix/server/api/routers/v1/sessions.py +2 -5
  21. phoenix/server/api/routers/v1/spans.py +35 -26
  22. phoenix/server/api/routers/v1/traces.py +11 -19
  23. phoenix/server/api/routers/v1/users.py +14 -23
  24. phoenix/server/api/routers/v1/utils.py +3 -7
  25. phoenix/server/app.py +6 -2
  26. phoenix/server/authorization.py +2 -3
  27. phoenix/server/bearer_auth.py +4 -5
  28. phoenix/server/cost_tracking/model_cost_manifest.json +54 -54
  29. phoenix/server/oauth2.py +174 -9
  30. phoenix/server/static/.vite/manifest.json +39 -39
  31. phoenix/server/static/assets/{components-BG6v0EM8.js → components-BvsExS75.js} +422 -387
  32. phoenix/server/static/assets/{index-CSVcULw1.js → index-iq8WDxat.js} +12 -12
  33. phoenix/server/static/assets/{pages-DgaM7kpM.js → pages-Ckg4SLQ9.js} +542 -488
  34. phoenix/server/static/assets/vendor-D2eEI-6h.js +914 -0
  35. phoenix/server/static/assets/{vendor-arizeai-DlOj0PQQ.js → vendor-arizeai-kfOei7nf.js} +2 -2
  36. phoenix/server/static/assets/{vendor-codemirror-B2PHH5yZ.js → vendor-codemirror-1bq_t1Ec.js} +3 -3
  37. phoenix/server/static/assets/{vendor-recharts-CKsi4IjN.js → vendor-recharts-DQ4xfrf4.js} +1 -1
  38. phoenix/server/static/assets/{vendor-shiki-DN26BkKE.js → vendor-shiki-GGmcIQxA.js} +1 -1
  39. phoenix/server/templates/index.html +1 -0
  40. phoenix/trace/attributes.py +80 -13
  41. phoenix/version.py +1 -1
  42. phoenix/server/static/assets/vendor-BqTEkGQU.js +0 -903
  43. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/WHEEL +0 -0
  44. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/entry_points.txt +0 -0
  45. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/IP_NOTICE +0 -0
  46. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,6 @@
1
+ import logging
1
2
  import re
2
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
3
4
  from datetime import timedelta
4
5
  from random import randrange
5
6
  from typing import Any, Optional, TypedDict
@@ -18,17 +19,19 @@ from sqlean.dbapi2 import IntegrityError as SQLiteIntegrityError # type: ignore
18
19
  from starlette.datastructures import URL, Secret, URLPath
19
20
  from starlette.responses import RedirectResponse
20
21
  from starlette.routing import Router
21
- from starlette.status import HTTP_302_FOUND
22
22
  from typing_extensions import Annotated, NotRequired, TypeGuard
23
23
 
24
24
  from phoenix.auth import (
25
25
  DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES,
26
+ PHOENIX_OAUTH2_CODE_VERIFIER_COOKIE_NAME,
26
27
  PHOENIX_OAUTH2_NONCE_COOKIE_NAME,
27
28
  PHOENIX_OAUTH2_STATE_COOKIE_NAME,
29
+ delete_oauth2_code_verifier_cookie,
28
30
  delete_oauth2_nonce_cookie,
29
31
  delete_oauth2_state_cookie,
30
32
  sanitize_email,
31
33
  set_access_token_cookie,
34
+ set_oauth2_code_verifier_cookie,
32
35
  set_oauth2_nonce_cookie,
33
36
  set_oauth2_state_cookie,
34
37
  set_refresh_token_cookie,
@@ -38,6 +41,7 @@ from phoenix.config import (
38
41
  get_env_disable_rate_limit,
39
42
  )
40
43
  from phoenix.db import models
44
+ from phoenix.server.api.auth_messages import AuthErrorCode
41
45
  from phoenix.server.bearer_auth import create_access_and_refresh_tokens
42
46
  from phoenix.server.oauth2 import OAuth2Client
43
47
  from phoenix.server.rate_limiters import (
@@ -50,6 +54,8 @@ from phoenix.server.utils import get_root_path, prepend_root_path
50
54
 
51
55
  _LOWERCASE_ALPHANUMS_AND_UNDERSCORES = r"[a-z0-9_]+"
52
56
 
57
+ logger = logging.getLogger(__name__)
58
+
53
59
  login_rate_limiter = fastapi_ip_rate_limiter(
54
60
  ServerRateLimiter(
55
61
  per_second_rate_limit=0.2,
@@ -88,11 +94,12 @@ async def login(
88
94
  idp_name: Annotated[str, Path(min_length=1, pattern=_LOWERCASE_ALPHANUMS_AND_UNDERSCORES)],
89
95
  return_url: Optional[str] = Query(default=None, alias="returnUrl"),
90
96
  ) -> RedirectResponse:
97
+ # Security Note: Query parameters should be treated as untrusted user input. Never display
98
+ # these values directly to users as they could be manipulated for XSS, phishing, or social
99
+ # engineering attacks.
100
+ if (oauth2_client := request.app.state.oauth2_clients.get_client(idp_name)) is None:
101
+ return _redirect_to_login(request=request, error="unknown_idp")
91
102
  secret = request.app.state.get_secret()
92
- if not isinstance(
93
- oauth2_client := request.app.state.oauth2_clients.get_client(idp_name), OAuth2Client
94
- ):
95
- return _redirect_to_login(request=request, error=f"Unknown IDP: {idp_name}.")
96
103
  if (referer := request.headers.get("referer")) is not None:
97
104
  # if the referer header is present, use it as the origin URL
98
105
  parsed_url = urlparse(referer)
@@ -113,7 +120,7 @@ async def login(
113
120
  assert isinstance(authorization_url := authorization_url_data.get("url"), str)
114
121
  assert isinstance(state := authorization_url_data.get("state"), str)
115
122
  assert isinstance(nonce := authorization_url_data.get("nonce"), str)
116
- response = RedirectResponse(url=authorization_url, status_code=HTTP_302_FOUND)
123
+ response = RedirectResponse(url=authorization_url, status_code=302)
117
124
  response = set_oauth2_state_cookie(
118
125
  response=response,
119
126
  state=state,
@@ -124,6 +131,12 @@ async def login(
124
131
  nonce=nonce,
125
132
  max_age=timedelta(minutes=DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES),
126
133
  )
134
+ if code_verifier := authorization_url_data.get("code_verifier"):
135
+ response = set_oauth2_code_verifier_cookie(
136
+ response=response,
137
+ code_verifier=code_verifier,
138
+ max_age=timedelta(minutes=DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES),
139
+ )
127
140
  return response
128
141
 
129
142
 
@@ -131,50 +144,96 @@ async def login(
131
144
  async def create_tokens(
132
145
  request: Request,
133
146
  idp_name: Annotated[str, Path(min_length=1, pattern=_LOWERCASE_ALPHANUMS_AND_UNDERSCORES)],
134
- state: str = Query(),
135
- authorization_code: str = Query(alias="code"),
147
+ state: str = Query(), # RFC 6749 §4.1.1: CSRF protection via state parameter
148
+ authorization_code: Optional[str] = Query(default=None, alias="code"), # RFC 6749 §4.1.2
149
+ error: Optional[str] = Query(default=None), # RFC 6749 §4.1.2.1: Error response
150
+ error_description: Optional[str] = Query(default=None),
136
151
  stored_state: str = Cookie(alias=PHOENIX_OAUTH2_STATE_COOKIE_NAME),
137
- stored_nonce: str = Cookie(alias=PHOENIX_OAUTH2_NONCE_COOKIE_NAME),
152
+ stored_nonce: str = Cookie(alias=PHOENIX_OAUTH2_NONCE_COOKIE_NAME), # OIDC Core §3.1.2.1
153
+ code_verifier: Optional[str] = Cookie(
154
+ default=None, alias=PHOENIX_OAUTH2_CODE_VERIFIER_COOKIE_NAME
155
+ ), # RFC 7636 §4.1
138
156
  ) -> RedirectResponse:
157
+ # Security Note: Query parameters should be treated as untrusted user input. Never display
158
+ # these values directly to users as they could be manipulated for XSS, phishing, or social
159
+ # engineering attacks.
160
+ if (oauth2_client := request.app.state.oauth2_clients.get_client(idp_name)) is None:
161
+ return _redirect_to_login(request=request, error="unknown_idp")
162
+ if error or error_description:
163
+ logger.error(
164
+ "OAuth2 authentication failed for IDP %s: error=%s, description=%s",
165
+ idp_name,
166
+ error,
167
+ error_description,
168
+ )
169
+ return _redirect_to_login(request=request, error="auth_failed")
170
+ if authorization_code is None:
171
+ logger.error("OAuth2 callback missing authorization code for IDP %s", idp_name)
172
+ return _redirect_to_login(request=request, error="auth_failed")
139
173
  secret = request.app.state.get_secret()
174
+ # RFC 6749 §10.12: CSRF protection - validate state parameter
140
175
  if state != stored_state:
141
- return _redirect_to_login(request=request, error=_INVALID_OAUTH2_STATE_MESSAGE)
176
+ return _redirect_to_login(request=request, error="invalid_state")
142
177
  try:
143
178
  payload = _parse_state_payload(secret=secret, state=state)
144
179
  except JoseError:
145
- return _redirect_to_login(request=request, error=_INVALID_OAUTH2_STATE_MESSAGE)
180
+ return _redirect_to_login(request=request, error="invalid_state")
146
181
  if (return_url := payload.get("return_url")) is not None and not _is_relative_url(
147
182
  unquote(return_url)
148
183
  ):
149
- return _redirect_to_login(request=request, error="Attempting login with unsafe return URL.")
184
+ return _redirect_to_login(request=request, error="unsafe_return_url")
150
185
  assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
151
186
  assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
152
187
  token_store: TokenStore = request.app.state.get_token_store()
153
- if not isinstance(
154
- oauth2_client := request.app.state.oauth2_clients.get_client(idp_name), OAuth2Client
155
- ):
156
- return _redirect_to_login(request=request, error=f"Unknown IDP: {idp_name}.")
157
188
  try:
158
- token_data = await oauth2_client.fetch_access_token(
189
+ # RFC 6749 §4.1.3: Token request - exchange authorization code for tokens
190
+ fetch_kwargs: dict[str, Any] = dict(
159
191
  state=state,
160
192
  code=authorization_code,
161
- redirect_uri=_get_create_tokens_endpoint(
193
+ redirect_uri=_get_create_tokens_endpoint( # RFC 6749 §3.1.2
162
194
  request=request, origin_url=payload["origin_url"], idp_name=idp_name
163
195
  ),
164
196
  )
165
- except OAuthError as error:
166
- return _redirect_to_login(request=request, error=str(error))
197
+ # PKCE validation: code_verifier is required when PKCE is enabled (RFC 7636 §4.5)
198
+ if oauth2_client.use_pkce:
199
+ if not code_verifier:
200
+ logger.error(
201
+ "PKCE enabled but code_verifier cookie missing for IDP %s. "
202
+ "This may indicate a cookie issue, CORS misconfiguration, or "
203
+ "browser compatibility problem.",
204
+ idp_name,
205
+ )
206
+ return _redirect_to_login(request=request, error="auth_failed")
207
+ fetch_kwargs["code_verifier"] = code_verifier
208
+ token_data = await oauth2_client.fetch_access_token(**fetch_kwargs)
209
+ except OAuthError as e:
210
+ logger.error("OAuth2 error for IDP %s: %s", idp_name, e)
211
+ return _redirect_to_login(request=request, error="oauth_error")
167
212
  _validate_token_data(token_data)
168
213
  if "id_token" not in token_data:
169
- return _redirect_to_login(
170
- request=request,
171
- error=f"OAuth2 IDP {idp_name} does not appear to support OpenID Connect.",
214
+ logger.error("OAuth2 IDP %s does not appear to support OpenID Connect", idp_name)
215
+ return _redirect_to_login(request=request, error="no_oidc_support")
216
+
217
+ id_token_claims = await oauth2_client.parse_id_token(token_data, nonce=stored_nonce)
218
+
219
+ if oauth2_client.has_sufficient_claims(id_token_claims):
220
+ user_claims = id_token_claims
221
+ else:
222
+ user_claims = await _fetch_and_merge_userinfo_claims(
223
+ oauth2_client, token_data, id_token_claims
172
224
  )
173
- user_info = await oauth2_client.parse_id_token(token_data, nonce=stored_nonce)
225
+
174
226
  try:
175
- user_info = _parse_user_info(user_info)
176
- except MissingEmailScope as error:
177
- return _redirect_to_login(request=request, error=str(error))
227
+ user_info = _parse_user_info(user_claims)
228
+ except (MissingEmailScope, InvalidUserInfo) as e:
229
+ logger.error("Error parsing user info for IDP %s: %s", idp_name, e)
230
+ return _redirect_to_login(request=request, error="missing_email_scope")
231
+
232
+ try:
233
+ oauth2_client.validate_access(user_info.claims)
234
+ except PermissionError as e:
235
+ logger.error("Access validation failed for IDP %s: %s", idp_name, e)
236
+ return _redirect_to_login(request=request, error="auth_failed")
178
237
 
179
238
  try:
180
239
  async with request.app.state.db() as session:
@@ -184,8 +243,12 @@ async def create_tokens(
184
243
  user_info=user_info,
185
244
  allow_sign_up=oauth2_client.allow_sign_up,
186
245
  )
187
- except (EmailAlreadyInUse, SignInNotAllowed) as error:
188
- return _redirect_to_login(request=request, error=str(error))
246
+ except EmailAlreadyInUse as e:
247
+ logger.error("Email already in use for IDP %s: %s", idp_name, e)
248
+ return _redirect_to_login(request=request, error="email_in_use")
249
+ except SignInNotAllowed as e:
250
+ logger.error("Sign in not allowed for IDP %s: %s", idp_name, e)
251
+ return _redirect_to_login(request=request, error="sign_in_not_allowed")
189
252
  access_token, refresh_token = await create_access_and_refresh_tokens(
190
253
  user=user,
191
254
  token_store=token_store,
@@ -195,7 +258,7 @@ async def create_tokens(
195
258
  redirect_path = prepend_root_path(request.scope, return_url or "/")
196
259
  response = RedirectResponse(
197
260
  url=redirect_path,
198
- status_code=HTTP_302_FOUND,
261
+ status_code=302,
199
262
  )
200
263
  response = set_access_token_cookie(
201
264
  response=response, access_token=access_token, max_age=access_token_expiry
@@ -205,6 +268,7 @@ async def create_tokens(
205
268
  )
206
269
  response = delete_oauth2_state_cookie(response)
207
270
  response = delete_oauth2_nonce_cookie(response)
271
+ response = delete_oauth2_code_verifier_cookie(response)
208
272
  return response
209
273
 
210
274
 
@@ -214,6 +278,7 @@ class UserInfo:
214
278
  email: str
215
279
  username: Optional[str] = None
216
280
  profile_picture_url: Optional[str] = None
281
+ claims: dict[str, Any] = field(default_factory=dict)
217
282
 
218
283
  def __post_init__(self) -> None:
219
284
  if not (idp_user_id := (self.idp_user_id or "").strip()):
@@ -228,9 +293,64 @@ class UserInfo:
228
293
  object.__setattr__(self, "profile_picture_url", profile_picture_url)
229
294
 
230
295
 
296
+ async def _fetch_and_merge_userinfo_claims(
297
+ oauth2_client: OAuth2Client,
298
+ token_data: dict[str, Any],
299
+ id_token_claims: dict[str, Any],
300
+ ) -> dict[str, Any]:
301
+ """
302
+ Fetch claims from UserInfo endpoint and merge with ID token claims.
303
+
304
+ Why this is necessary (OIDC Core §5.4, §5.5):
305
+ When claims are requested via scopes (e.g., "profile", "email"), OIDC Core §5.4
306
+ specifies which claims are "REQUESTED" but does not mandate WHERE they must be
307
+ returned. Similarly, §5.5 allows requesting specific claims via the "claims"
308
+ parameter, but providers have discretion on whether to return them in the ID token
309
+ or UserInfo response. In practice, providers often return certain claims (especially
310
+ large ones like groups) only via UserInfo to keep ID tokens compact.
311
+
312
+ The UserInfo endpoint (OIDC Core §5.3) provides additional claims beyond what's
313
+ in the ID token, such as group memberships or custom attributes. This function:
314
+
315
+ 1. Calls the UserInfo endpoint using the access token (OIDC Core §5.3.1, RFC 6750)
316
+ 2. Merges userinfo claims with ID token claims
317
+ 3. ID token claims override userinfo claims when both contain the same claim
318
+
319
+ Why ID token takes precedence (OIDC Core §5.3.2):
320
+ - ID tokens are signed JWTs that have been cryptographically verified
321
+ - UserInfo responses may be unsigned
322
+ - Signed claims are the authoritative source when present in both
323
+
324
+ Fallback behavior:
325
+ If the UserInfo request fails, returns only ID token claims. The returned claims
326
+ may be incomplete (missing email or groups), but subsequent validation will catch this:
327
+ - Missing email: _parse_user_info() raises MissingEmailScope
328
+ - Missing groups: validate_access() raises PermissionError if access is denied
329
+
330
+ Args:
331
+ oauth2_client: The OAuth2 client to use for fetching userinfo
332
+ token_data: Token response containing the access token (RFC 6749 §5.1)
333
+ id_token_claims: Claims from the verified ID token (OIDC Core §3.1.3.3)
334
+
335
+ Returns:
336
+ Merged claims dictionary with ID token claims overriding userinfo claims
337
+ """
338
+ try:
339
+ # OIDC Core §5.3.1: UserInfo request authenticated with access token
340
+ userinfo_claims = await oauth2_client.userinfo(token=token_data)
341
+ # ID token claims take precedence (signed and verified)
342
+ return {**userinfo_claims, **id_token_claims}
343
+ except Exception:
344
+ # Fallback: ID token has essential claims for authentication
345
+ return id_token_claims
346
+
347
+
231
348
  def _validate_token_data(token_data: dict[str, Any]) -> None:
232
349
  """
233
350
  Performs basic validations on the token data returned by the IDP.
351
+
352
+ RFC 6749 §5.1: Successful response must include access_token and token_type.
353
+ RFC 6750 §1.1: Bearer token type for HTTP authentication.
234
354
  """
235
355
  assert isinstance(token_data.get("access_token"), str)
236
356
  assert isinstance(token_type := token_data.get("token_type"), str)
@@ -240,25 +360,107 @@ def _validate_token_data(token_data: dict[str, Any]) -> None:
240
360
  def _parse_user_info(user_info: dict[str, Any]) -> UserInfo:
241
361
  """
242
362
  Parses user info from the IDP's ID token.
243
- """
244
- assert isinstance(subject := user_info.get("sub"), (str, int))
245
- idp_user_id = str(subject)
363
+
364
+ Validates required OIDC claims and extracts user information according to the
365
+ OpenID Connect Core 1.0 specification.
366
+
367
+ Args:
368
+ user_info: Claims from the ID token (validated JWT payload)
369
+
370
+ Returns:
371
+ UserInfo object with validated user data
372
+
373
+ Raises:
374
+ InvalidUserInfo: If required claims are missing or malformed
375
+ MissingEmailScope: If email claim is missing or invalid
376
+
377
+ ID Token Required Claims (OIDC Core §2, §3.1.3.3):
378
+ - iss (issuer): Identifier for the OpenID Provider
379
+ - sub (subject): Unique identifier for the End-User at the Issuer
380
+ - aud (audience): Client ID this ID token is intended for
381
+ - exp (expiration): Expiration time
382
+ - iat (issued at): Time the JWT was issued
383
+ - nonce (if sent in auth request): Value sent in the Authentication Request
384
+
385
+ Application-Required Claims:
386
+ - email: Required by this application for user identification
387
+
388
+ Optional Standard Claims (OIDC Core §5.1):
389
+ - name: Full name
390
+ - picture: Profile picture URL
391
+ - Other profile, email, address, and phone claims
392
+
393
+ Note: While iss, sub, aud, exp, iat are REQUIRED in all ID tokens per spec,
394
+ other claims like email, name, groups are optional and may appear in the ID token,
395
+ UserInfo response, or both depending on what was requested and provider implementation.
396
+ """
397
+ # Validate 'sub' claim (OIDC required, MUST be a string per spec)
398
+ subject = user_info.get("sub")
399
+ if subject is None:
400
+ raise InvalidUserInfo(
401
+ "Missing required 'sub' claim in ID token. "
402
+ "Please check your OIDC provider configuration."
403
+ )
404
+
405
+ # OIDC spec: sub MUST be a string, but some IDPs send integers
406
+ # Convert to string for compatibility
407
+ if isinstance(subject, (str, int)):
408
+ idp_user_id = str(subject).strip()
409
+ else:
410
+ raise InvalidUserInfo(
411
+ f"Invalid 'sub' claim type: {type(subject).__name__}. Expected string or integer."
412
+ )
413
+
414
+ if not idp_user_id:
415
+ raise InvalidUserInfo("The 'sub' claim cannot be empty.")
416
+
417
+ # Validate 'email' claim (application requirement)
246
418
  email = user_info.get("email")
247
- if not isinstance(email, str):
419
+ if not isinstance(email, str) or not email.strip():
248
420
  raise MissingEmailScope(
249
- "Please ensure your OIDC provider is configured to use the 'email' scope."
421
+ "Missing or invalid 'email' claim. "
422
+ "Please ensure your OIDC provider is configured to include the 'email' scope."
250
423
  )
424
+ email = email.strip()
425
+
426
+ # Optional: 'name' claim (Full name)
427
+ username = user_info.get("name")
428
+ if username is not None:
429
+ if not isinstance(username, str):
430
+ # Some IDPs might send unexpected types; ignore gracefully
431
+ username = None
432
+ else:
433
+ username = username.strip() or None
434
+
435
+ # Optional: 'picture' claim (Profile picture URL)
436
+ profile_picture_url = user_info.get("picture")
437
+ if profile_picture_url is not None:
438
+ if not isinstance(profile_picture_url, str):
439
+ # Some IDPs might send unexpected types; ignore gracefully
440
+ profile_picture_url = None
441
+ else:
442
+ profile_picture_url = profile_picture_url.strip() or None
443
+
444
+ # Keep only non-empty claim values for downstream processing
445
+ def _has_value(v: Any) -> bool:
446
+ """Check if a claim value is considered non-empty."""
447
+ if v is None:
448
+ return False
449
+ if isinstance(v, str):
450
+ return bool(v.strip())
451
+ if isinstance(v, (list, dict, set, tuple)):
452
+ return len(v) > 0
453
+ # Include all other types (numbers, booleans, etc.)
454
+ return True
455
+
456
+ filtered_claims = {k: v for k, v in user_info.items() if _has_value(v)}
251
457
 
252
- assert isinstance(username := user_info.get("name"), str) or username is None
253
- assert (
254
- isinstance(profile_picture_url := user_info.get("picture"), str)
255
- or profile_picture_url is None
256
- )
257
458
  return UserInfo(
258
459
  idp_user_id=idp_user_id,
259
460
  email=email,
260
461
  username=username,
261
462
  profile_picture_url=profile_picture_url,
463
+ claims=filtered_claims,
262
464
  )
263
465
 
264
466
 
@@ -560,9 +762,18 @@ class MissingEmailScope(Exception):
560
762
  pass
561
763
 
562
764
 
563
- def _redirect_to_login(*, request: Request, error: str) -> RedirectResponse:
765
+ class InvalidUserInfo(Exception):
766
+ """
767
+ Raised when the OIDC user info is malformed or missing required claims.
768
+ """
769
+
770
+ pass
771
+
772
+
773
+ def _redirect_to_login(*, request: Request, error: AuthErrorCode) -> RedirectResponse:
564
774
  """
565
- Creates a RedirectResponse to the login page to display an error message.
775
+ Creates a RedirectResponse to the login page to display an error code.
776
+ The error code will be validated and mapped to a user-friendly message on the frontend.
566
777
  """
567
778
  # TODO: this needs some cleanup
568
779
  login_path = prepend_root_path(
@@ -572,6 +783,7 @@ def _redirect_to_login(*, request: Request, error: str) -> RedirectResponse:
572
783
  response = RedirectResponse(url=url)
573
784
  response = delete_oauth2_state_cookie(response)
574
785
  response = delete_oauth2_nonce_cookie(response)
786
+ response = delete_oauth2_code_verifier_cookie(response)
575
787
  return response
576
788
 
577
789
 
@@ -661,7 +873,4 @@ def _is_oauth2_state_payload(maybe_state_payload: Any) -> TypeGuard[_OAuth2State
661
873
 
662
874
 
663
875
  _JWT_ALGORITHM = "HS256"
664
- _INVALID_OAUTH2_STATE_MESSAGE = (
665
- "Received invalid state parameter during OAuth2 authorization code flow for IDP {idp_name}."
666
- )
667
876
  _RELATIVE_URL_PATTERN = re.compile(r"^/($|\w)")
@@ -1,6 +1,5 @@
1
1
  from fastapi import APIRouter, Depends, HTTPException, Request
2
2
  from fastapi.security import APIKeyHeader
3
- from starlette.status import HTTP_403_FORBIDDEN
4
3
 
5
4
  from phoenix.server.bearer_auth import is_authenticated
6
5
 
@@ -30,7 +29,7 @@ async def prevent_access_in_read_only_mode(request: Request) -> None:
30
29
  if request.app.state.read_only:
31
30
  raise HTTPException(
32
31
  detail="The Phoenix REST API is disabled in read-only mode.",
33
- status_code=HTTP_403_FORBIDDEN,
32
+ status_code=403,
34
33
  )
35
34
 
36
35
 
@@ -57,7 +56,7 @@ def create_v1_router(authentication_enabled: bool) -> APIRouter:
57
56
  dependencies=dependencies,
58
57
  responses=add_errors_to_responses(
59
58
  [
60
- HTTP_403_FORBIDDEN # adds a 403 response to routes in the generated OpenAPI schema
59
+ 403 # adds a 403 response to routes in the generated OpenAPI schema
61
60
  ]
62
61
  ),
63
62
  )
@@ -7,11 +7,6 @@ from sqlalchemy import delete, select
7
7
  from sqlalchemy.exc import IntegrityError as PostgreSQLIntegrityError
8
8
  from sqlean.dbapi2 import IntegrityError as SQLiteIntegrityError # type: ignore[import-untyped]
9
9
  from starlette.requests import Request
10
- from starlette.status import (
11
- HTTP_400_BAD_REQUEST,
12
- HTTP_404_NOT_FOUND,
13
- HTTP_409_CONFLICT,
14
- )
15
10
  from strawberry.relay import GlobalID
16
11
  from typing_extensions import TypeAlias, assert_never
17
12
 
@@ -206,7 +201,7 @@ async def list_annotation_configs(
206
201
  except ValueError:
207
202
  raise HTTPException(
208
203
  detail=f"Invalid cursor: {cursor}",
209
- status_code=HTTP_400_BAD_REQUEST,
204
+ status_code=400,
210
205
  )
211
206
  if cursor_gid.type_name not in (
212
207
  CategoricalAnnotationConfigType.__name__,
@@ -215,7 +210,7 @@ async def list_annotation_configs(
215
210
  ):
216
211
  raise HTTPException(
217
212
  detail=f"Invalid cursor: {cursor}",
218
- status_code=HTTP_400_BAD_REQUEST,
213
+ status_code=400,
219
214
  )
220
215
  cursor_id = int(cursor_gid.node_id)
221
216
 
@@ -261,9 +256,7 @@ async def get_annotation_config_by_name_or_id(
261
256
  query = query.where(models.AnnotationConfig.name == config_identifier)
262
257
  config = await session.scalar(query)
263
258
  if not config:
264
- raise HTTPException(
265
- status_code=HTTP_404_NOT_FOUND, detail="Annotation configuration not found"
266
- )
259
+ raise HTTPException(status_code=404, detail="Annotation configuration not found")
267
260
  return GetAnnotationConfigResponseBody(data=db_to_api_annotation_config(config))
268
261
 
269
262
 
@@ -282,7 +275,7 @@ async def create_annotation_config(
282
275
  try:
283
276
  db_config = _to_db_annotation_config(input_config)
284
277
  except ValueError as error:
285
- raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(error))
278
+ raise HTTPException(status_code=400, detail=str(error))
286
279
 
287
280
  async with request.app.state.db() as session:
288
281
  annotation_config = models.AnnotationConfig(
@@ -294,7 +287,7 @@ async def create_annotation_config(
294
287
  await session.commit()
295
288
  except (PostgreSQLIntegrityError, SQLiteIntegrityError):
296
289
  raise HTTPException(
297
- status_code=HTTP_409_CONFLICT,
290
+ status_code=409,
298
291
  detail="The name of the annotation configuration is already taken",
299
292
  )
300
293
  return CreateAnnotationConfigResponseBody(
@@ -321,22 +314,18 @@ async def update_annotation_config(
321
314
  ContinuousAnnotationConfigType.__name__,
322
315
  FreeformAnnotationConfigType.__name__,
323
316
  ):
324
- raise HTTPException(
325
- status_code=HTTP_400_BAD_REQUEST, detail="Invalid annotation configuration ID"
326
- )
317
+ raise HTTPException(status_code=400, detail="Invalid annotation configuration ID")
327
318
  config_rowid = int(config_gid.node_id)
328
319
 
329
320
  try:
330
321
  db_config = _to_db_annotation_config(input_config)
331
322
  except ValueError as error:
332
- raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(error))
323
+ raise HTTPException(status_code=400, detail=str(error))
333
324
 
334
325
  async with request.app.state.db() as session:
335
326
  existing_config = await session.get(models.AnnotationConfig, config_rowid)
336
327
  if not existing_config:
337
- raise HTTPException(
338
- status_code=HTTP_404_NOT_FOUND, detail="Annotation configuration not found"
339
- )
328
+ raise HTTPException(status_code=404, detail="Annotation configuration not found")
340
329
 
341
330
  existing_config.name = input_config.name
342
331
  existing_config.config = db_config
@@ -345,7 +334,7 @@ async def update_annotation_config(
345
334
  await session.commit()
346
335
  except (PostgreSQLIntegrityError, SQLiteIntegrityError):
347
336
  raise HTTPException(
348
- status_code=HTTP_409_CONFLICT,
337
+ status_code=409,
349
338
  detail="The name of the annotation configuration is already taken",
350
339
  )
351
340
 
@@ -366,9 +355,7 @@ async def delete_annotation_config(
366
355
  ContinuousAnnotationConfigType.__name__,
367
356
  FreeformAnnotationConfigType.__name__,
368
357
  ):
369
- raise HTTPException(
370
- status_code=HTTP_400_BAD_REQUEST, detail="Invalid annotation configuration ID"
371
- )
358
+ raise HTTPException(status_code=400, detail="Invalid annotation configuration ID")
372
359
  config_rowid = int(config_gid.node_id)
373
360
  async with request.app.state.db() as session:
374
361
  stmt = (
@@ -378,9 +365,7 @@ async def delete_annotation_config(
378
365
  )
379
366
  annotation_config = await session.scalar(stmt)
380
367
  if annotation_config is None:
381
- raise HTTPException(
382
- status_code=HTTP_404_NOT_FOUND, detail="Annotation configuration not found"
383
- )
368
+ raise HTTPException(status_code=404, detail="Annotation configuration not found")
384
369
  await session.commit()
385
370
  return DeleteAnnotationConfigResponseBody(data=db_to_api_annotation_config(annotation_config))
386
371
 
@@ -400,9 +385,7 @@ def _get_annotation_config_db_id(config_gid: str) -> int:
400
385
  def _reserve_note_annotation_name(data: AnnotationConfigData) -> str:
401
386
  name = data.name
402
387
  if name == "note":
403
- raise HTTPException(
404
- status_code=HTTP_409_CONFLICT, detail="The name 'note' is reserved for span notes"
405
- )
388
+ raise HTTPException(status_code=409, detail="The name 'note' is reserved for span notes")
406
389
  return name
407
390
 
408
391