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.
- {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/METADATA +2 -1
- {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/RECORD +45 -44
- phoenix/auth.py +19 -0
- phoenix/config.py +302 -53
- phoenix/db/README.md +546 -28
- phoenix/server/api/auth_messages.py +46 -0
- phoenix/server/api/routers/auth.py +21 -30
- phoenix/server/api/routers/oauth2.py +255 -46
- phoenix/server/api/routers/v1/__init__.py +2 -3
- phoenix/server/api/routers/v1/annotation_configs.py +12 -29
- phoenix/server/api/routers/v1/annotations.py +21 -22
- phoenix/server/api/routers/v1/datasets.py +38 -56
- phoenix/server/api/routers/v1/documents.py +2 -3
- phoenix/server/api/routers/v1/evaluations.py +12 -24
- phoenix/server/api/routers/v1/experiment_evaluations.py +2 -3
- phoenix/server/api/routers/v1/experiment_runs.py +9 -10
- phoenix/server/api/routers/v1/experiments.py +16 -17
- phoenix/server/api/routers/v1/projects.py +15 -21
- phoenix/server/api/routers/v1/prompts.py +30 -31
- phoenix/server/api/routers/v1/sessions.py +2 -5
- phoenix/server/api/routers/v1/spans.py +35 -26
- phoenix/server/api/routers/v1/traces.py +11 -19
- phoenix/server/api/routers/v1/users.py +14 -23
- phoenix/server/api/routers/v1/utils.py +3 -7
- phoenix/server/app.py +6 -2
- phoenix/server/authorization.py +2 -3
- phoenix/server/bearer_auth.py +4 -5
- phoenix/server/cost_tracking/model_cost_manifest.json +54 -54
- phoenix/server/oauth2.py +174 -9
- phoenix/server/static/.vite/manifest.json +39 -39
- phoenix/server/static/assets/{components-BG6v0EM8.js → components-BvsExS75.js} +422 -387
- phoenix/server/static/assets/{index-CSVcULw1.js → index-iq8WDxat.js} +12 -12
- phoenix/server/static/assets/{pages-DgaM7kpM.js → pages-Ckg4SLQ9.js} +542 -488
- phoenix/server/static/assets/vendor-D2eEI-6h.js +914 -0
- phoenix/server/static/assets/{vendor-arizeai-DlOj0PQQ.js → vendor-arizeai-kfOei7nf.js} +2 -2
- phoenix/server/static/assets/{vendor-codemirror-B2PHH5yZ.js → vendor-codemirror-1bq_t1Ec.js} +3 -3
- phoenix/server/static/assets/{vendor-recharts-CKsi4IjN.js → vendor-recharts-DQ4xfrf4.js} +1 -1
- phoenix/server/static/assets/{vendor-shiki-DN26BkKE.js → vendor-shiki-GGmcIQxA.js} +1 -1
- phoenix/server/templates/index.html +1 -0
- phoenix/trace/attributes.py +80 -13
- phoenix/version.py +1 -1
- phoenix/server/static/assets/vendor-BqTEkGQU.js +0 -903
- {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/IP_NOTICE +0 -0
- {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=
|
|
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=
|
|
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=
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
225
|
+
|
|
174
226
|
try:
|
|
175
|
-
user_info = _parse_user_info(
|
|
176
|
-
except MissingEmailScope as
|
|
177
|
-
|
|
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
|
|
188
|
-
|
|
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=
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|