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.
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/METADATA +2 -1
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/RECORD +37 -37
- phoenix/auth.py +19 -0
- phoenix/config.py +302 -53
- phoenix/db/README.md +546 -28
- phoenix/server/api/routers/auth.py +21 -30
- phoenix/server/api/routers/oauth2.py +213 -24
- 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 +1 -2
- phoenix/server/authorization.py +2 -3
- phoenix/server/bearer_auth.py +4 -5
- phoenix/server/oauth2.py +172 -5
- phoenix/server/static/.vite/manifest.json +9 -9
- phoenix/server/static/assets/{components-Bs8eJEpU.js → components-BvsExS75.js} +110 -120
- phoenix/server/static/assets/{index-C6WEu5UP.js → index-iq8WDxat.js} +1 -1
- phoenix/server/static/assets/{pages-D-n2pkoG.js → pages-Ckg4SLQ9.js} +4 -4
- phoenix/trace/attributes.py +80 -13
- phoenix/version.py +1 -1
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/IP_NOTICE +0 -0
- {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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
163
|
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
173
164
|
if expiration_time.timestamp() < datetime.now().timestamp():
|
|
174
|
-
raise HTTPException(status_code=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
285
|
+
status_code=422,
|
|
295
286
|
detail="Email required",
|
|
296
287
|
)
|
|
297
288
|
MISSING_PASSWORD = HTTPException(
|
|
298
|
-
status_code=
|
|
289
|
+
status_code=422,
|
|
299
290
|
detail="Password required",
|
|
300
291
|
)
|
|
301
292
|
SMTP_UNAVAILABLE = HTTPException(
|
|
302
|
-
status_code=
|
|
293
|
+
status_code=503,
|
|
303
294
|
detail="SMTP server not configured",
|
|
304
295
|
)
|
|
305
296
|
INVALID_TOKEN = HTTPException(
|
|
306
|
-
status_code=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
193
|
-
except MissingEmailScope as e:
|
|
194
|
-
logger.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)
|
|
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=
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
"
|
|
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=
|
|
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
|
)
|