arize-phoenix 12.3.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 (37) hide show
  1. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/METADATA +2 -1
  2. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/RECORD +37 -37
  3. phoenix/auth.py +19 -0
  4. phoenix/config.py +302 -53
  5. phoenix/db/README.md +546 -28
  6. phoenix/server/api/routers/auth.py +21 -30
  7. phoenix/server/api/routers/oauth2.py +213 -24
  8. phoenix/server/api/routers/v1/__init__.py +2 -3
  9. phoenix/server/api/routers/v1/annotation_configs.py +12 -29
  10. phoenix/server/api/routers/v1/annotations.py +21 -22
  11. phoenix/server/api/routers/v1/datasets.py +38 -56
  12. phoenix/server/api/routers/v1/documents.py +2 -3
  13. phoenix/server/api/routers/v1/evaluations.py +12 -24
  14. phoenix/server/api/routers/v1/experiment_evaluations.py +2 -3
  15. phoenix/server/api/routers/v1/experiment_runs.py +9 -10
  16. phoenix/server/api/routers/v1/experiments.py +16 -17
  17. phoenix/server/api/routers/v1/projects.py +15 -21
  18. phoenix/server/api/routers/v1/prompts.py +30 -31
  19. phoenix/server/api/routers/v1/sessions.py +2 -5
  20. phoenix/server/api/routers/v1/spans.py +35 -26
  21. phoenix/server/api/routers/v1/traces.py +11 -19
  22. phoenix/server/api/routers/v1/users.py +14 -23
  23. phoenix/server/api/routers/v1/utils.py +3 -7
  24. phoenix/server/app.py +1 -2
  25. phoenix/server/authorization.py +2 -3
  26. phoenix/server/bearer_auth.py +4 -5
  27. phoenix/server/oauth2.py +172 -5
  28. phoenix/server/static/.vite/manifest.json +9 -9
  29. phoenix/server/static/assets/{components-Bs8eJEpU.js → components-BvsExS75.js} +110 -120
  30. phoenix/server/static/assets/{index-C6WEu5UP.js → index-iq8WDxat.js} +1 -1
  31. phoenix/server/static/assets/{pages-D-n2pkoG.js → pages-Ckg4SLQ9.js} +4 -4
  32. phoenix/trace/attributes.py +80 -13
  33. phoenix/version.py +1 -1
  34. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/WHEEL +0 -0
  35. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/entry_points.txt +0 -0
  36. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/IP_NOTICE +0 -0
  37. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,15 +7,6 @@ from urllib.parse import urlencode, urlparse, urlunparse
7
7
  from fastapi import APIRouter, Depends, HTTPException, Request, Response
8
8
  from sqlalchemy import func, select
9
9
  from sqlalchemy.orm import joinedload
10
- from starlette.status import (
11
- HTTP_204_NO_CONTENT,
12
- HTTP_302_FOUND,
13
- HTTP_401_UNAUTHORIZED,
14
- HTTP_403_FORBIDDEN,
15
- HTTP_404_NOT_FOUND,
16
- HTTP_422_UNPROCESSABLE_ENTITY,
17
- HTTP_503_SERVICE_UNAVAILABLE,
18
- )
19
10
 
20
11
  from phoenix.auth import (
21
12
  DEFAULT_SECRET_LENGTH,
@@ -76,7 +67,7 @@ router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_de
76
67
  @router.post("/login")
77
68
  async def login(request: Request) -> Response:
78
69
  if get_env_disable_basic_auth():
79
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
70
+ raise HTTPException(status_code=403)
80
71
  assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
81
72
  assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
82
73
  token_store: TokenStore = request.app.state.get_token_store()
@@ -85,7 +76,7 @@ async def login(request: Request) -> Response:
85
76
  password = data.get("password")
86
77
 
87
78
  if not email or not password:
88
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Email and password required")
79
+ raise HTTPException(status_code=401, detail="Email and password required")
89
80
 
90
81
  # Sanitize email by trimming and lowercasing
91
82
  email = sanitize_email(email)
@@ -101,14 +92,14 @@ async def login(request: Request) -> Response:
101
92
  or (password_hash := user.password_hash) is None
102
93
  or (salt := user.password_salt) is None
103
94
  ):
104
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
95
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
105
96
 
106
97
  loop = asyncio.get_running_loop()
107
98
  password_is_valid = partial(
108
99
  is_valid_password, password=password, salt=salt, password_hash=password_hash
109
100
  )
110
101
  if not await loop.run_in_executor(None, password_is_valid):
111
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
102
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
112
103
 
113
104
  access_token, refresh_token = await create_access_and_refresh_tokens(
114
105
  token_store=token_store,
@@ -116,7 +107,7 @@ async def login(request: Request) -> Response:
116
107
  access_token_expiry=access_token_expiry,
117
108
  refresh_token_expiry=refresh_token_expiry,
118
109
  )
119
- response = Response(status_code=HTTP_204_NO_CONTENT)
110
+ response = Response(status_code=204)
120
111
  response = set_access_token_cookie(
121
112
  response=response, access_token=access_token, max_age=access_token_expiry
122
113
  )
@@ -146,7 +137,7 @@ async def logout(
146
137
  await token_store.log_out(user_id)
147
138
  redirect_path = "/logout" if get_env_disable_basic_auth() else "/login"
148
139
  redirect_url = prepend_root_path(request.scope, redirect_path)
149
- response = Response(status_code=HTTP_302_FOUND, headers={"Location": redirect_url})
140
+ response = Response(status_code=302, headers={"Location": redirect_url})
150
141
  response = delete_access_token_cookie(response)
151
142
  response = delete_refresh_token_cookie(response)
152
143
  response = delete_oauth2_state_cookie(response)
@@ -159,7 +150,7 @@ async def refresh_tokens(request: Request) -> Response:
159
150
  assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
160
151
  assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
161
152
  if (refresh_token := request.cookies.get(PHOENIX_REFRESH_TOKEN_COOKIE_NAME)) is None:
162
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
153
+ raise HTTPException(status_code=401, detail="Missing refresh token")
163
154
  token_store: TokenStore = request.app.state.get_token_store()
164
155
  refresh_token_claims = await token_store.read(Token(refresh_token))
165
156
  if (
@@ -169,9 +160,9 @@ async def refresh_tokens(request: Request) -> Response:
169
160
  or (user_id := int(refresh_token_claims.subject)) is None
170
161
  or (expiration_time := refresh_token_claims.expiration_time) is None
171
162
  ):
172
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
163
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
173
164
  if expiration_time.timestamp() < datetime.now().timestamp():
174
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Expired refresh token")
165
+ raise HTTPException(status_code=401, detail="Expired refresh token")
175
166
  await token_store.revoke(refresh_token_id)
176
167
 
177
168
  if (
@@ -189,14 +180,14 @@ async def refresh_tokens(request: Request) -> Response:
189
180
  select(models.User).filter_by(id=user_id).options(joinedload(models.User.role))
190
181
  )
191
182
  ) is None:
192
- raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found")
183
+ raise HTTPException(status_code=404, detail="User not found")
193
184
  access_token, refresh_token = await create_access_and_refresh_tokens(
194
185
  token_store=token_store,
195
186
  user=user,
196
187
  access_token_expiry=access_token_expiry,
197
188
  refresh_token_expiry=refresh_token_expiry,
198
189
  )
199
- response = Response(status_code=HTTP_204_NO_CONTENT)
190
+ response = Response(status_code=204)
200
191
  response = set_access_token_cookie(
201
192
  response=response, access_token=access_token, max_age=access_token_expiry
202
193
  )
@@ -209,7 +200,7 @@ async def refresh_tokens(request: Request) -> Response:
209
200
  @router.post("/password-reset-email")
210
201
  async def initiate_password_reset(request: Request) -> Response:
211
202
  if get_env_disable_basic_auth():
212
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
203
+ raise HTTPException(status_code=403)
213
204
  data = await request.json()
214
205
  if not (email := data.get("email")):
215
206
  raise MISSING_EMAIL
@@ -231,7 +222,7 @@ async def initiate_password_reset(request: Request) -> Response:
231
222
  )
232
223
  if user is None or user.auth_method != "LOCAL":
233
224
  # Withold privileged information
234
- return Response(status_code=HTTP_204_NO_CONTENT)
225
+ return Response(status_code=204)
235
226
  token_store: TokenStore = request.app.state.get_token_store()
236
227
  if user.password_reset_token:
237
228
  await token_store.revoke(PasswordResetTokenId(user.password_reset_token.id))
@@ -247,13 +238,13 @@ async def initiate_password_reset(request: Request) -> Response:
247
238
  components = (url.scheme, url.netloc, path, "", query_string, "")
248
239
  reset_url = urlunparse(components)
249
240
  await sender.send_password_reset_email(email, reset_url)
250
- return Response(status_code=HTTP_204_NO_CONTENT)
241
+ return Response(status_code=204)
251
242
 
252
243
 
253
244
  @router.post("/password-reset")
254
245
  async def reset_password(request: Request) -> Response:
255
246
  if get_env_disable_basic_auth():
256
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
247
+ raise HTTPException(status_code=403)
257
248
  data = await request.json()
258
249
  if not (password := data.get("password")):
259
250
  raise MISSING_PASSWORD
@@ -270,7 +261,7 @@ async def reset_password(request: Request) -> Response:
270
261
  user = await session.scalar(select(models.User).filter_by(id=int(user_id)))
271
262
  if user is None or user.auth_method != "LOCAL":
272
263
  # Withold privileged information
273
- return Response(status_code=HTTP_204_NO_CONTENT)
264
+ return Response(status_code=204)
274
265
  validate_password_format(password)
275
266
  user.password_salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
276
267
  loop = asyncio.get_running_loop()
@@ -281,7 +272,7 @@ async def reset_password(request: Request) -> Response:
281
272
  async with request.app.state.db() as session:
282
273
  session.add(user)
283
274
  await session.flush()
284
- response = Response(status_code=HTTP_204_NO_CONTENT)
275
+ response = Response(status_code=204)
285
276
  assert (token_id := claims.token_id)
286
277
  await token_store.revoke(token_id)
287
278
  await token_store.log_out(UserId(user.id))
@@ -291,18 +282,18 @@ async def reset_password(request: Request) -> Response:
291
282
  LOGIN_FAILED_MESSAGE = "Invalid email and/or password"
292
283
 
293
284
  MISSING_EMAIL = HTTPException(
294
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
285
+ status_code=422,
295
286
  detail="Email required",
296
287
  )
297
288
  MISSING_PASSWORD = HTTPException(
298
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
289
+ status_code=422,
299
290
  detail="Password required",
300
291
  )
301
292
  SMTP_UNAVAILABLE = HTTPException(
302
- status_code=HTTP_503_SERVICE_UNAVAILABLE,
293
+ status_code=503,
303
294
  detail="SMTP server not configured",
304
295
  )
305
296
  INVALID_TOKEN = HTTPException(
306
- status_code=HTTP_401_UNAUTHORIZED,
297
+ status_code=401,
307
298
  detail="Invalid token",
308
299
  )
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  import re
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from datetime import timedelta
5
5
  from random import randrange
6
6
  from typing import Any, Optional, TypedDict
@@ -19,17 +19,19 @@ from sqlean.dbapi2 import IntegrityError as SQLiteIntegrityError # type: ignore
19
19
  from starlette.datastructures import URL, Secret, URLPath
20
20
  from starlette.responses import RedirectResponse
21
21
  from starlette.routing import Router
22
- from starlette.status import HTTP_302_FOUND
23
22
  from typing_extensions import Annotated, NotRequired, TypeGuard
24
23
 
25
24
  from phoenix.auth import (
26
25
  DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES,
26
+ PHOENIX_OAUTH2_CODE_VERIFIER_COOKIE_NAME,
27
27
  PHOENIX_OAUTH2_NONCE_COOKIE_NAME,
28
28
  PHOENIX_OAUTH2_STATE_COOKIE_NAME,
29
+ delete_oauth2_code_verifier_cookie,
29
30
  delete_oauth2_nonce_cookie,
30
31
  delete_oauth2_state_cookie,
31
32
  sanitize_email,
32
33
  set_access_token_cookie,
34
+ set_oauth2_code_verifier_cookie,
33
35
  set_oauth2_nonce_cookie,
34
36
  set_oauth2_state_cookie,
35
37
  set_refresh_token_cookie,
@@ -41,6 +43,7 @@ from phoenix.config import (
41
43
  from phoenix.db import models
42
44
  from phoenix.server.api.auth_messages import AuthErrorCode
43
45
  from phoenix.server.bearer_auth import create_access_and_refresh_tokens
46
+ from phoenix.server.oauth2 import OAuth2Client
44
47
  from phoenix.server.rate_limiters import (
45
48
  ServerRateLimiter,
46
49
  fastapi_ip_rate_limiter,
@@ -117,7 +120,7 @@ async def login(
117
120
  assert isinstance(authorization_url := authorization_url_data.get("url"), str)
118
121
  assert isinstance(state := authorization_url_data.get("state"), str)
119
122
  assert isinstance(nonce := authorization_url_data.get("nonce"), str)
120
- response = RedirectResponse(url=authorization_url, status_code=HTTP_302_FOUND)
123
+ response = RedirectResponse(url=authorization_url, status_code=302)
121
124
  response = set_oauth2_state_cookie(
122
125
  response=response,
123
126
  state=state,
@@ -128,6 +131,12 @@ async def login(
128
131
  nonce=nonce,
129
132
  max_age=timedelta(minutes=DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES),
130
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
+ )
131
140
  return response
132
141
 
133
142
 
@@ -135,12 +144,15 @@ async def login(
135
144
  async def create_tokens(
136
145
  request: Request,
137
146
  idp_name: Annotated[str, Path(min_length=1, pattern=_LOWERCASE_ALPHANUMS_AND_UNDERSCORES)],
138
- state: str = Query(),
139
- authorization_code: Optional[str] = Query(default=None, alias="code"),
140
- error: Optional[str] = Query(default=None),
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
141
150
  error_description: Optional[str] = Query(default=None),
142
151
  stored_state: str = Cookie(alias=PHOENIX_OAUTH2_STATE_COOKIE_NAME),
143
- 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
144
156
  ) -> RedirectResponse:
145
157
  # Security Note: Query parameters should be treated as untrusted user input. Never display
146
158
  # these values directly to users as they could be manipulated for XSS, phishing, or social
@@ -159,6 +171,7 @@ async def create_tokens(
159
171
  logger.error("OAuth2 callback missing authorization code for IDP %s", idp_name)
160
172
  return _redirect_to_login(request=request, error="auth_failed")
161
173
  secret = request.app.state.get_secret()
174
+ # RFC 6749 §10.12: CSRF protection - validate state parameter
162
175
  if state != stored_state:
163
176
  return _redirect_to_login(request=request, error="invalid_state")
164
177
  try:
@@ -173,13 +186,26 @@ async def create_tokens(
173
186
  assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
174
187
  token_store: TokenStore = request.app.state.get_token_store()
175
188
  try:
176
- 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(
177
191
  state=state,
178
192
  code=authorization_code,
179
- redirect_uri=_get_create_tokens_endpoint(
193
+ redirect_uri=_get_create_tokens_endpoint( # RFC 6749 §3.1.2
180
194
  request=request, origin_url=payload["origin_url"], idp_name=idp_name
181
195
  ),
182
196
  )
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)
183
209
  except OAuthError as e:
184
210
  logger.error("OAuth2 error for IDP %s: %s", idp_name, e)
185
211
  return _redirect_to_login(request=request, error="oauth_error")
@@ -187,13 +213,28 @@ async def create_tokens(
187
213
  if "id_token" not in token_data:
188
214
  logger.error("OAuth2 IDP %s does not appear to support OpenID Connect", idp_name)
189
215
  return _redirect_to_login(request=request, error="no_oidc_support")
190
- user_info = await oauth2_client.parse_id_token(token_data, nonce=stored_nonce)
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
224
+ )
225
+
191
226
  try:
192
- user_info = _parse_user_info(user_info)
193
- except MissingEmailScope as e:
194
- logger.error("Missing email scope for IDP %s: %s", idp_name, e)
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)
195
230
  return _redirect_to_login(request=request, error="missing_email_scope")
196
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")
237
+
197
238
  try:
198
239
  async with request.app.state.db() as session:
199
240
  user = await _process_oauth2_user(
@@ -217,7 +258,7 @@ async def create_tokens(
217
258
  redirect_path = prepend_root_path(request.scope, return_url or "/")
218
259
  response = RedirectResponse(
219
260
  url=redirect_path,
220
- status_code=HTTP_302_FOUND,
261
+ status_code=302,
221
262
  )
222
263
  response = set_access_token_cookie(
223
264
  response=response, access_token=access_token, max_age=access_token_expiry
@@ -227,6 +268,7 @@ async def create_tokens(
227
268
  )
228
269
  response = delete_oauth2_state_cookie(response)
229
270
  response = delete_oauth2_nonce_cookie(response)
271
+ response = delete_oauth2_code_verifier_cookie(response)
230
272
  return response
231
273
 
232
274
 
@@ -236,6 +278,7 @@ class UserInfo:
236
278
  email: str
237
279
  username: Optional[str] = None
238
280
  profile_picture_url: Optional[str] = None
281
+ claims: dict[str, Any] = field(default_factory=dict)
239
282
 
240
283
  def __post_init__(self) -> None:
241
284
  if not (idp_user_id := (self.idp_user_id or "").strip()):
@@ -250,9 +293,64 @@ class UserInfo:
250
293
  object.__setattr__(self, "profile_picture_url", profile_picture_url)
251
294
 
252
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
+
253
348
  def _validate_token_data(token_data: dict[str, Any]) -> None:
254
349
  """
255
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.
256
354
  """
257
355
  assert isinstance(token_data.get("access_token"), str)
258
356
  assert isinstance(token_type := token_data.get("token_type"), str)
@@ -262,25 +360,107 @@ def _validate_token_data(token_data: dict[str, Any]) -> None:
262
360
  def _parse_user_info(user_info: dict[str, Any]) -> UserInfo:
263
361
  """
264
362
  Parses user info from the IDP's ID token.
265
- """
266
- assert isinstance(subject := user_info.get("sub"), (str, int))
267
- 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)
268
418
  email = user_info.get("email")
269
- if not isinstance(email, str):
419
+ if not isinstance(email, str) or not email.strip():
270
420
  raise MissingEmailScope(
271
- "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."
272
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)}
273
457
 
274
- assert isinstance(username := user_info.get("name"), str) or username is None
275
- assert (
276
- isinstance(profile_picture_url := user_info.get("picture"), str)
277
- or profile_picture_url is None
278
- )
279
458
  return UserInfo(
280
459
  idp_user_id=idp_user_id,
281
460
  email=email,
282
461
  username=username,
283
462
  profile_picture_url=profile_picture_url,
463
+ claims=filtered_claims,
284
464
  )
285
465
 
286
466
 
@@ -582,6 +762,14 @@ class MissingEmailScope(Exception):
582
762
  pass
583
763
 
584
764
 
765
+ class InvalidUserInfo(Exception):
766
+ """
767
+ Raised when the OIDC user info is malformed or missing required claims.
768
+ """
769
+
770
+ pass
771
+
772
+
585
773
  def _redirect_to_login(*, request: Request, error: AuthErrorCode) -> RedirectResponse:
586
774
  """
587
775
  Creates a RedirectResponse to the login page to display an error code.
@@ -595,6 +783,7 @@ def _redirect_to_login(*, request: Request, error: AuthErrorCode) -> RedirectRes
595
783
  response = RedirectResponse(url=url)
596
784
  response = delete_oauth2_state_cookie(response)
597
785
  response = delete_oauth2_nonce_cookie(response)
786
+ response = delete_oauth2_code_verifier_cookie(response)
598
787
  return response
599
788
 
600
789
 
@@ -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
  )