arize-phoenix 11.23.1__py3-none-any.whl → 12.28.1__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.
Files changed (221) hide show
  1. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +61 -36
  2. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/RECORD +212 -162
  3. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
  4. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/IP_NOTICE +1 -1
  5. phoenix/__generated__/__init__.py +0 -0
  6. phoenix/__generated__/classification_evaluator_configs/__init__.py +20 -0
  7. phoenix/__generated__/classification_evaluator_configs/_document_relevance_classification_evaluator_config.py +17 -0
  8. phoenix/__generated__/classification_evaluator_configs/_hallucination_classification_evaluator_config.py +17 -0
  9. phoenix/__generated__/classification_evaluator_configs/_models.py +18 -0
  10. phoenix/__generated__/classification_evaluator_configs/_tool_selection_classification_evaluator_config.py +17 -0
  11. phoenix/__init__.py +2 -1
  12. phoenix/auth.py +27 -2
  13. phoenix/config.py +1594 -81
  14. phoenix/db/README.md +546 -28
  15. phoenix/db/bulk_inserter.py +119 -116
  16. phoenix/db/engines.py +140 -33
  17. phoenix/db/facilitator.py +22 -1
  18. phoenix/db/helpers.py +818 -65
  19. phoenix/db/iam_auth.py +64 -0
  20. phoenix/db/insertion/dataset.py +133 -1
  21. phoenix/db/insertion/document_annotation.py +9 -6
  22. phoenix/db/insertion/evaluation.py +2 -3
  23. phoenix/db/insertion/helpers.py +2 -2
  24. phoenix/db/insertion/session_annotation.py +176 -0
  25. phoenix/db/insertion/span_annotation.py +3 -4
  26. phoenix/db/insertion/trace_annotation.py +3 -4
  27. phoenix/db/insertion/types.py +41 -18
  28. phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
  29. phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
  30. phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
  31. phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
  32. phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
  33. phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
  34. phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
  35. phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
  36. phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
  37. phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
  38. phoenix/db/models.py +364 -56
  39. phoenix/db/pg_config.py +10 -0
  40. phoenix/db/types/trace_retention.py +7 -6
  41. phoenix/experiments/functions.py +69 -19
  42. phoenix/inferences/inferences.py +1 -2
  43. phoenix/server/api/auth.py +9 -0
  44. phoenix/server/api/auth_messages.py +46 -0
  45. phoenix/server/api/context.py +60 -0
  46. phoenix/server/api/dataloaders/__init__.py +36 -0
  47. phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
  48. phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
  49. phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
  50. phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
  51. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  52. phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
  53. phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
  54. phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
  55. phoenix/server/api/dataloaders/dataset_labels.py +36 -0
  56. phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
  57. phoenix/server/api/dataloaders/document_evaluations.py +6 -9
  58. phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
  59. phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
  60. phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
  61. phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
  62. phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
  63. phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
  64. phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
  65. phoenix/server/api/dataloaders/record_counts.py +37 -10
  66. phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
  67. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
  68. phoenix/server/api/dataloaders/span_cost_summary_by_project.py +28 -14
  69. phoenix/server/api/dataloaders/span_costs.py +3 -9
  70. phoenix/server/api/dataloaders/table_fields.py +2 -2
  71. phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
  72. phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
  73. phoenix/server/api/exceptions.py +5 -1
  74. phoenix/server/api/helpers/playground_clients.py +263 -83
  75. phoenix/server/api/helpers/playground_spans.py +2 -1
  76. phoenix/server/api/helpers/playground_users.py +26 -0
  77. phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
  78. phoenix/server/api/helpers/prompts/models.py +61 -19
  79. phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
  80. phoenix/server/api/input_types/ChatCompletionInput.py +3 -0
  81. phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
  82. phoenix/server/api/input_types/DatasetFilter.py +5 -2
  83. phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
  84. phoenix/server/api/input_types/GenerativeModelInput.py +3 -0
  85. phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
  86. phoenix/server/api/input_types/PromptVersionInput.py +47 -1
  87. phoenix/server/api/input_types/SpanSort.py +3 -2
  88. phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
  89. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  90. phoenix/server/api/mutations/__init__.py +8 -0
  91. phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
  92. phoenix/server/api/mutations/api_key_mutations.py +15 -20
  93. phoenix/server/api/mutations/chat_mutations.py +106 -37
  94. phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
  95. phoenix/server/api/mutations/dataset_mutations.py +21 -16
  96. phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
  97. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  98. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  99. phoenix/server/api/mutations/model_mutations.py +11 -9
  100. phoenix/server/api/mutations/project_mutations.py +4 -4
  101. phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
  102. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  103. phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
  104. phoenix/server/api/mutations/prompt_mutations.py +65 -129
  105. phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
  106. phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
  107. phoenix/server/api/mutations/trace_annotations_mutations.py +13 -8
  108. phoenix/server/api/mutations/trace_mutations.py +3 -3
  109. phoenix/server/api/mutations/user_mutations.py +55 -26
  110. phoenix/server/api/queries.py +501 -617
  111. phoenix/server/api/routers/__init__.py +2 -2
  112. phoenix/server/api/routers/auth.py +141 -87
  113. phoenix/server/api/routers/ldap.py +229 -0
  114. phoenix/server/api/routers/oauth2.py +349 -101
  115. phoenix/server/api/routers/v1/__init__.py +22 -4
  116. phoenix/server/api/routers/v1/annotation_configs.py +19 -30
  117. phoenix/server/api/routers/v1/annotations.py +455 -13
  118. phoenix/server/api/routers/v1/datasets.py +355 -68
  119. phoenix/server/api/routers/v1/documents.py +142 -0
  120. phoenix/server/api/routers/v1/evaluations.py +20 -28
  121. phoenix/server/api/routers/v1/experiment_evaluations.py +16 -6
  122. phoenix/server/api/routers/v1/experiment_runs.py +335 -59
  123. phoenix/server/api/routers/v1/experiments.py +475 -47
  124. phoenix/server/api/routers/v1/projects.py +16 -50
  125. phoenix/server/api/routers/v1/prompts.py +50 -39
  126. phoenix/server/api/routers/v1/sessions.py +108 -0
  127. phoenix/server/api/routers/v1/spans.py +156 -96
  128. phoenix/server/api/routers/v1/traces.py +51 -77
  129. phoenix/server/api/routers/v1/users.py +64 -24
  130. phoenix/server/api/routers/v1/utils.py +3 -7
  131. phoenix/server/api/subscriptions.py +257 -93
  132. phoenix/server/api/types/Annotation.py +90 -23
  133. phoenix/server/api/types/ApiKey.py +13 -17
  134. phoenix/server/api/types/AuthMethod.py +1 -0
  135. phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
  136. phoenix/server/api/types/Dataset.py +199 -72
  137. phoenix/server/api/types/DatasetExample.py +88 -18
  138. phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
  139. phoenix/server/api/types/DatasetLabel.py +57 -0
  140. phoenix/server/api/types/DatasetSplit.py +98 -0
  141. phoenix/server/api/types/DatasetVersion.py +49 -4
  142. phoenix/server/api/types/DocumentAnnotation.py +212 -0
  143. phoenix/server/api/types/Experiment.py +215 -68
  144. phoenix/server/api/types/ExperimentComparison.py +3 -9
  145. phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
  146. phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
  147. phoenix/server/api/types/ExperimentRun.py +120 -70
  148. phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
  149. phoenix/server/api/types/GenerativeModel.py +95 -42
  150. phoenix/server/api/types/GenerativeProvider.py +1 -1
  151. phoenix/server/api/types/ModelInterface.py +7 -2
  152. phoenix/server/api/types/PlaygroundModel.py +12 -2
  153. phoenix/server/api/types/Project.py +218 -185
  154. phoenix/server/api/types/ProjectSession.py +146 -29
  155. phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
  156. phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
  157. phoenix/server/api/types/Prompt.py +119 -39
  158. phoenix/server/api/types/PromptLabel.py +42 -25
  159. phoenix/server/api/types/PromptVersion.py +11 -8
  160. phoenix/server/api/types/PromptVersionTag.py +65 -25
  161. phoenix/server/api/types/Span.py +130 -123
  162. phoenix/server/api/types/SpanAnnotation.py +189 -42
  163. phoenix/server/api/types/SystemApiKey.py +65 -1
  164. phoenix/server/api/types/Trace.py +184 -53
  165. phoenix/server/api/types/TraceAnnotation.py +149 -50
  166. phoenix/server/api/types/User.py +128 -33
  167. phoenix/server/api/types/UserApiKey.py +73 -26
  168. phoenix/server/api/types/node.py +10 -0
  169. phoenix/server/api/types/pagination.py +11 -2
  170. phoenix/server/app.py +154 -36
  171. phoenix/server/authorization.py +5 -4
  172. phoenix/server/bearer_auth.py +13 -5
  173. phoenix/server/cost_tracking/cost_model_lookup.py +42 -14
  174. phoenix/server/cost_tracking/model_cost_manifest.json +1085 -194
  175. phoenix/server/daemons/generative_model_store.py +61 -9
  176. phoenix/server/daemons/span_cost_calculator.py +10 -8
  177. phoenix/server/dml_event.py +13 -0
  178. phoenix/server/email/sender.py +29 -2
  179. phoenix/server/grpc_server.py +9 -9
  180. phoenix/server/jwt_store.py +8 -6
  181. phoenix/server/ldap.py +1449 -0
  182. phoenix/server/main.py +9 -3
  183. phoenix/server/oauth2.py +330 -12
  184. phoenix/server/prometheus.py +43 -6
  185. phoenix/server/rate_limiters.py +4 -9
  186. phoenix/server/retention.py +33 -20
  187. phoenix/server/session_filters.py +49 -0
  188. phoenix/server/static/.vite/manifest.json +51 -53
  189. phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
  190. phoenix/server/static/assets/{index-BPCwGQr8.js → index-CTQoemZv.js} +42 -35
  191. phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
  192. phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
  193. phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
  194. phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
  195. phoenix/server/static/assets/{vendor-recharts-Bw30oz1A.js → vendor-recharts-V9cwpXsm.js} +7 -7
  196. phoenix/server/static/assets/{vendor-shiki-DZajAPeq.js → vendor-shiki-Do--csgv.js} +1 -1
  197. phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
  198. phoenix/server/templates/index.html +7 -1
  199. phoenix/server/thread_server.py +1 -2
  200. phoenix/server/utils.py +74 -0
  201. phoenix/session/client.py +55 -1
  202. phoenix/session/data_extractor.py +5 -0
  203. phoenix/session/evaluation.py +8 -4
  204. phoenix/session/session.py +44 -8
  205. phoenix/settings.py +2 -0
  206. phoenix/trace/attributes.py +80 -13
  207. phoenix/trace/dsl/query.py +2 -0
  208. phoenix/trace/projects.py +5 -0
  209. phoenix/utilities/template_formatters.py +1 -1
  210. phoenix/version.py +1 -1
  211. phoenix/server/api/types/Evaluation.py +0 -39
  212. phoenix/server/static/assets/components-D0DWAf0l.js +0 -5650
  213. phoenix/server/static/assets/pages-Creyamao.js +0 -8612
  214. phoenix/server/static/assets/vendor-CU36oj8y.js +0 -905
  215. phoenix/server/static/assets/vendor-CqDb5u4o.css +0 -1
  216. phoenix/server/static/assets/vendor-arizeai-Ctgw0e1G.js +0 -168
  217. phoenix/server/static/assets/vendor-codemirror-Cojjzqb9.js +0 -25
  218. phoenix/server/static/assets/vendor-three-BLWp5bic.js +0 -2998
  219. phoenix/utilities/deprecation.py +0 -31
  220. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
  221. {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,10 +1,10 @@
1
- from .auth import router as auth_router
1
+ from .auth import create_auth_router
2
2
  from .embeddings import create_embeddings_router
3
3
  from .oauth2 import router as oauth2_router
4
4
  from .v1 import create_v1_router
5
5
 
6
6
  __all__ = [
7
- "auth_router",
7
+ "create_auth_router",
8
8
  "create_embeddings_router",
9
9
  "create_v1_router",
10
10
  "oauth2_router",
@@ -1,22 +1,14 @@
1
1
  import asyncio
2
+ import logging
2
3
  import secrets
3
4
  from datetime import datetime, timedelta, timezone
4
5
  from functools import partial
5
- from pathlib import Path
6
+ from typing import TYPE_CHECKING
6
7
  from urllib.parse import urlencode, urlparse, urlunparse
7
8
 
8
9
  from fastapi import APIRouter, Depends, HTTPException, Request, Response
9
10
  from sqlalchemy import func, select
10
11
  from sqlalchemy.orm import joinedload
11
- from starlette.status import (
12
- HTTP_204_NO_CONTENT,
13
- HTTP_302_FOUND,
14
- HTTP_401_UNAUTHORIZED,
15
- HTTP_403_FORBIDDEN,
16
- HTTP_404_NOT_FOUND,
17
- HTTP_422_UNPROCESSABLE_ENTITY,
18
- HTTP_503_SERVICE_UNAVAILABLE,
19
- )
20
12
 
21
13
  from phoenix.auth import (
22
14
  DEFAULT_SECRET_LENGTH,
@@ -38,9 +30,9 @@ from phoenix.config import (
38
30
  get_base_url,
39
31
  get_env_disable_basic_auth,
40
32
  get_env_disable_rate_limit,
41
- get_env_host_root_path,
42
33
  )
43
34
  from phoenix.db import models
35
+ from phoenix.server.api.routers.ldap import get_or_create_ldap_user
44
36
  from phoenix.server.bearer_auth import PhoenixUser, create_access_and_refresh_tokens
45
37
  from phoenix.server.email.types import EmailSender
46
38
  from phoenix.server.rate_limiters import ServerRateLimiter, fastapi_ip_rate_limiter
@@ -52,6 +44,12 @@ from phoenix.server.types import (
52
44
  TokenStore,
53
45
  UserId,
54
46
  )
47
+ from phoenix.server.utils import prepend_root_path
48
+
49
+ if TYPE_CHECKING:
50
+ from phoenix.server.ldap import LDAPAuthenticator
51
+
52
+ logger = logging.getLogger(__name__)
55
53
 
56
54
  rate_limiter = ServerRateLimiter(
57
55
  per_second_rate_limit=0.2,
@@ -59,34 +57,63 @@ rate_limiter = ServerRateLimiter(
59
57
  partition_seconds=60,
60
58
  active_partitions=2,
61
59
  )
62
- login_rate_limiter = fastapi_ip_rate_limiter(
63
- rate_limiter,
64
- paths=[
60
+
61
+
62
+ def create_auth_router(ldap_enabled: bool = False) -> APIRouter:
63
+ """Create auth router with all authentication endpoints.
64
+
65
+ Creates a fresh router instance each time to avoid global state issues
66
+ (e.g., route accumulation in tests).
67
+
68
+ Security: Only registers the /ldap/login endpoint when LDAP is actually configured.
69
+ This prevents information disclosure and reduces attack surface.
70
+
71
+ Args:
72
+ ldap_enabled: Whether LDAP authentication is configured
73
+
74
+ Returns:
75
+ APIRouter: Authentication router with all endpoints registered
76
+ """
77
+ # Build rate limiter paths based on configuration
78
+ rate_limited_paths = [
65
79
  "/auth/login",
66
80
  "/auth/logout",
67
81
  "/auth/refresh",
68
82
  "/auth/password-reset-email",
69
83
  "/auth/password-reset",
70
- ],
71
- )
84
+ ]
85
+ if ldap_enabled:
86
+ rate_limited_paths.append("/auth/ldap/login")
72
87
 
73
- auth_dependencies = [Depends(login_rate_limiter)] if not get_env_disable_rate_limit() else []
74
- router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_dependencies)
88
+ login_rate_limiter = fastapi_ip_rate_limiter(rate_limiter, paths=rate_limited_paths)
89
+ auth_dependencies = [Depends(login_rate_limiter)] if not get_env_disable_rate_limit() else []
75
90
 
91
+ router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_dependencies)
76
92
 
77
- @router.post("/login")
78
- async def login(request: Request) -> Response:
93
+ # Register all authentication endpoints
94
+ router.add_api_route("/login", _login, methods=["POST"])
95
+ router.add_api_route("/logout", _logout, methods=["GET"])
96
+ router.add_api_route("/refresh", _refresh_tokens, methods=["POST"])
97
+ router.add_api_route("/password-reset-email", _initiate_password_reset, methods=["POST"])
98
+ router.add_api_route("/password-reset", _reset_password, methods=["POST"])
99
+
100
+ # Conditionally add LDAP endpoint only if configured
101
+ if ldap_enabled:
102
+ router.add_api_route("/ldap/login", _ldap_login, methods=["POST"])
103
+
104
+ return router
105
+
106
+
107
+ async def _login(request: Request) -> Response:
108
+ """Authenticate user via email/password and return access/refresh tokens."""
79
109
  if get_env_disable_basic_auth():
80
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
81
- assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
82
- assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
83
- token_store: TokenStore = request.app.state.get_token_store()
110
+ raise HTTPException(status_code=403)
84
111
  data = await request.json()
85
112
  email = data.get("email")
86
113
  password = data.get("password")
87
114
 
88
115
  if not email or not password:
89
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Email and password required")
116
+ raise HTTPException(status_code=401, detail="Email and password required")
90
117
 
91
118
  # Sanitize email by trimming and lowercasing
92
119
  email = sanitize_email(email)
@@ -102,35 +129,20 @@ async def login(request: Request) -> Response:
102
129
  or (password_hash := user.password_hash) is None
103
130
  or (salt := user.password_salt) is None
104
131
  ):
105
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
132
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
106
133
 
107
134
  loop = asyncio.get_running_loop()
108
135
  password_is_valid = partial(
109
136
  is_valid_password, password=password, salt=salt, password_hash=password_hash
110
137
  )
111
138
  if not await loop.run_in_executor(None, password_is_valid):
112
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
139
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
113
140
 
114
- access_token, refresh_token = await create_access_and_refresh_tokens(
115
- token_store=token_store,
116
- user=user,
117
- access_token_expiry=access_token_expiry,
118
- refresh_token_expiry=refresh_token_expiry,
119
- )
120
- response = Response(status_code=HTTP_204_NO_CONTENT)
121
- response = set_access_token_cookie(
122
- response=response, access_token=access_token, max_age=access_token_expiry
123
- )
124
- response = set_refresh_token_cookie(
125
- response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
126
- )
127
- return response
141
+ return await _create_auth_response(request, user)
128
142
 
129
143
 
130
- @router.get("/logout")
131
- async def logout(
132
- request: Request,
133
- ) -> Response:
144
+ async def _logout(request: Request) -> Response:
145
+ """Log out user by revoking tokens and clearing cookies."""
134
146
  token_store: TokenStore = request.app.state.get_token_store()
135
147
  user_id = None
136
148
  if isinstance(user := request.user, PhoenixUser):
@@ -145,8 +157,9 @@ async def logout(
145
157
  user_id = subject
146
158
  if user_id:
147
159
  await token_store.log_out(user_id)
148
- redirect_url = "/logout" if get_env_disable_basic_auth() else "/login"
149
- response = Response(status_code=HTTP_302_FOUND, headers={"Location": redirect_url})
160
+ redirect_path = "/logout" if get_env_disable_basic_auth() else "/login"
161
+ redirect_url = prepend_root_path(request.scope, redirect_path)
162
+ response = Response(status_code=302, headers={"Location": redirect_url})
150
163
  response = delete_access_token_cookie(response)
151
164
  response = delete_refresh_token_cookie(response)
152
165
  response = delete_oauth2_state_cookie(response)
@@ -154,12 +167,10 @@ async def logout(
154
167
  return response
155
168
 
156
169
 
157
- @router.post("/refresh")
158
- async def refresh_tokens(request: Request) -> Response:
159
- assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
160
- assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
170
+ async def _refresh_tokens(request: Request) -> Response:
171
+ """Refresh access and refresh tokens."""
161
172
  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")
173
+ raise HTTPException(status_code=401, detail="Missing refresh token")
163
174
  token_store: TokenStore = request.app.state.get_token_store()
164
175
  refresh_token_claims = await token_store.read(Token(refresh_token))
165
176
  if (
@@ -169,9 +180,9 @@ async def refresh_tokens(request: Request) -> Response:
169
180
  or (user_id := int(refresh_token_claims.subject)) is None
170
181
  or (expiration_time := refresh_token_claims.expiration_time) is None
171
182
  ):
172
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
173
- if expiration_time.timestamp() < datetime.now().timestamp():
174
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Expired refresh token")
183
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
184
+ if expiration_time.timestamp() <= datetime.now(timezone.utc).timestamp():
185
+ raise HTTPException(status_code=401, detail="Expired refresh token")
175
186
  await token_store.revoke(refresh_token_id)
176
187
 
177
188
  if (
@@ -189,27 +200,15 @@ async def refresh_tokens(request: Request) -> Response:
189
200
  select(models.User).filter_by(id=user_id).options(joinedload(models.User.role))
190
201
  )
191
202
  ) is None:
192
- raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found")
193
- access_token, refresh_token = await create_access_and_refresh_tokens(
194
- token_store=token_store,
195
- user=user,
196
- access_token_expiry=access_token_expiry,
197
- refresh_token_expiry=refresh_token_expiry,
198
- )
199
- response = Response(status_code=HTTP_204_NO_CONTENT)
200
- response = set_access_token_cookie(
201
- response=response, access_token=access_token, max_age=access_token_expiry
202
- )
203
- response = set_refresh_token_cookie(
204
- response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
205
- )
206
- return response
203
+ raise HTTPException(status_code=404, detail="User not found")
204
+
205
+ return await _create_auth_response(request, user)
207
206
 
208
207
 
209
- @router.post("/password-reset-email")
210
- async def initiate_password_reset(request: Request) -> Response:
208
+ async def _initiate_password_reset(request: Request) -> Response:
209
+ """Send password reset email to user."""
211
210
  if get_env_disable_basic_auth():
212
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
211
+ raise HTTPException(status_code=403)
213
212
  data = await request.json()
214
213
  if not (email := data.get("email")):
215
214
  raise MISSING_EMAIL
@@ -231,7 +230,7 @@ async def initiate_password_reset(request: Request) -> Response:
231
230
  )
232
231
  if user is None or user.auth_method != "LOCAL":
233
232
  # Withold privileged information
234
- return Response(status_code=HTTP_204_NO_CONTENT)
233
+ return Response(status_code=204)
235
234
  token_store: TokenStore = request.app.state.get_token_store()
236
235
  if user.password_reset_token:
237
236
  await token_store.revoke(PasswordResetTokenId(user.password_reset_token.id))
@@ -242,18 +241,18 @@ async def initiate_password_reset(request: Request) -> Response:
242
241
  )
243
242
  token, _ = await token_store.create_password_reset_token(password_reset_token_claims)
244
243
  url = urlparse(request.headers.get("referer") or get_base_url())
245
- path = Path(get_env_host_root_path()) / "reset-password-with-token"
244
+ path = prepend_root_path(request.scope, "/reset-password-with-token")
246
245
  query_string = urlencode(dict(token=token))
247
- components = (url.scheme, url.netloc, path.as_posix(), "", query_string, "")
246
+ components = (url.scheme, url.netloc, path, "", query_string, "")
248
247
  reset_url = urlunparse(components)
249
248
  await sender.send_password_reset_email(email, reset_url)
250
- return Response(status_code=HTTP_204_NO_CONTENT)
249
+ return Response(status_code=204)
251
250
 
252
251
 
253
- @router.post("/password-reset")
254
- async def reset_password(request: Request) -> Response:
252
+ async def _reset_password(request: Request) -> Response:
253
+ """Reset user password using a valid reset token."""
255
254
  if get_env_disable_basic_auth():
256
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
255
+ raise HTTPException(status_code=403)
257
256
  data = await request.json()
258
257
  if not (password := data.get("password")):
259
258
  raise MISSING_PASSWORD
@@ -262,7 +261,7 @@ async def reset_password(request: Request) -> Response:
262
261
  not (token := data.get("token"))
263
262
  or not isinstance((claims := await token_store.read(token)), PasswordResetTokenClaims)
264
263
  or not claims.expiration_time
265
- or claims.expiration_time < datetime.now(timezone.utc)
264
+ or claims.expiration_time <= datetime.now(timezone.utc)
266
265
  ):
267
266
  raise INVALID_TOKEN
268
267
  assert (user_id := claims.subject)
@@ -270,7 +269,7 @@ async def reset_password(request: Request) -> Response:
270
269
  user = await session.scalar(select(models.User).filter_by(id=int(user_id)))
271
270
  if user is None or user.auth_method != "LOCAL":
272
271
  # Withold privileged information
273
- return Response(status_code=HTTP_204_NO_CONTENT)
272
+ return Response(status_code=204)
274
273
  validate_password_format(password)
275
274
  user.password_salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
276
275
  loop = asyncio.get_running_loop()
@@ -281,28 +280,83 @@ async def reset_password(request: Request) -> Response:
281
280
  async with request.app.state.db() as session:
282
281
  session.add(user)
283
282
  await session.flush()
284
- response = Response(status_code=HTTP_204_NO_CONTENT)
283
+ response = Response(status_code=204)
285
284
  assert (token_id := claims.token_id)
286
285
  await token_store.revoke(token_id)
287
286
  await token_store.log_out(UserId(user.id))
288
287
  return response
289
288
 
290
289
 
290
+ async def _ldap_login(request: Request) -> Response:
291
+ """Authenticate user via LDAP and return access/refresh tokens."""
292
+ # Use cached authenticator instance to avoid re-parsing TLS config on every request
293
+ authenticator: LDAPAuthenticator | None = getattr(request.app.state, "ldap_authenticator", None)
294
+
295
+ if not authenticator:
296
+ raise HTTPException(
297
+ status_code=503, detail="LDAP authentication is not configured on this server"
298
+ )
299
+
300
+ data = await request.json()
301
+ username = data.get("username")
302
+ password = data.get("password")
303
+
304
+ if not username or not password:
305
+ raise HTTPException(status_code=401, detail="Username and password required")
306
+
307
+ # Authenticate against LDAP (reused authenticator, already parsed TLS config)
308
+ user_info = await authenticator.authenticate(username, password)
309
+
310
+ if not user_info:
311
+ # Generic error message to prevent username enumeration
312
+ raise HTTPException(status_code=401, detail="Invalid username and/or password")
313
+
314
+ # Get or create user in Phoenix database
315
+ async with request.app.state.db() as session:
316
+ user = await get_or_create_ldap_user(session, user_info, authenticator.config)
317
+
318
+ return await _create_auth_response(request, user)
319
+
320
+
321
+ async def _create_auth_response(request: Request, user: models.User) -> Response:
322
+ """
323
+ Creates access and refresh tokens for the user and sets them as cookies in the response.
324
+ """
325
+ token_store: TokenStore = request.app.state.get_token_store()
326
+ assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
327
+ assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
328
+
329
+ access_token, refresh_token = await create_access_and_refresh_tokens(
330
+ token_store=token_store,
331
+ user=user,
332
+ access_token_expiry=access_token_expiry,
333
+ refresh_token_expiry=refresh_token_expiry,
334
+ )
335
+ response = Response(status_code=204)
336
+ response = set_access_token_cookie(
337
+ response=response, access_token=access_token, max_age=access_token_expiry
338
+ )
339
+ response = set_refresh_token_cookie(
340
+ response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
341
+ )
342
+ return response
343
+
344
+
291
345
  LOGIN_FAILED_MESSAGE = "Invalid email and/or password"
292
346
 
293
347
  MISSING_EMAIL = HTTPException(
294
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
348
+ status_code=422,
295
349
  detail="Email required",
296
350
  )
297
351
  MISSING_PASSWORD = HTTPException(
298
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
352
+ status_code=422,
299
353
  detail="Password required",
300
354
  )
301
355
  SMTP_UNAVAILABLE = HTTPException(
302
- status_code=HTTP_503_SERVICE_UNAVAILABLE,
356
+ status_code=503,
303
357
  detail="SMTP server not configured",
304
358
  )
305
359
  INVALID_TOKEN = HTTPException(
306
- status_code=HTTP_401_UNAUTHORIZED,
360
+ status_code=401,
307
361
  detail="Invalid token",
308
362
  )
@@ -0,0 +1,229 @@
1
+ import logging
2
+ import secrets
3
+ from typing import cast
4
+
5
+ from fastapi import HTTPException
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy.orm import joinedload
9
+
10
+ from phoenix.auth import sanitize_email
11
+ from phoenix.config import LDAPConfig
12
+ from phoenix.db import models
13
+ from phoenix.server.ldap import (
14
+ LDAP_CLIENT_ID_MARKER,
15
+ LDAPUserInfo,
16
+ generate_null_email_marker,
17
+ is_ldap_user,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ async def get_or_create_ldap_user(
24
+ session: AsyncSession,
25
+ user_info: LDAPUserInfo,
26
+ ldap_config: LDAPConfig,
27
+ ) -> models.User:
28
+ """
29
+ Retrieves an existing LDAP user or creates a new one.
30
+
31
+ User Identity Strategy:
32
+ Phoenix identifies LDAP users using a stable identifier. The strategy
33
+ depends on whether PHOENIX_LDAP_ATTR_UNIQUE_ID is configured:
34
+
35
+ 1. If PHOENIX_LDAP_ATTR_UNIQUE_ID is set (e.g., "objectGUID" or "entryUUID"):
36
+ - Stores the immutable LDAP unique ID in oauth2_user_id
37
+ - Primary lookup by oauth2_user_id, fallback by email
38
+ - Survives: DN changes, email changes, renames, OU moves, domain consolidation
39
+ - This is how enterprise IAM systems (Okta, Azure AD Connect) work
40
+
41
+ 2. Otherwise (default):
42
+ - oauth2_user_id is NULL (no redundant email storage)
43
+ - Lookup by email column directly
44
+ - Survives: DN changes, OU moves, renames
45
+ - Simple setup for most organizations
46
+
47
+ Null Email Marker Mode:
48
+ When PHOENIX_LDAP_ATTR_EMAIL is "null", the LDAP directory doesn't have
49
+ email attributes. In this mode:
50
+ - unique_id is required (enforced at config validation)
51
+ - Lookup is by unique_id only (no email fallback)
52
+ - A null email marker is generated: "\\ue000NULL(stopgap){md5(unique_id)}"
53
+
54
+ Admin-Provisioned Users:
55
+ Admins can pre-create users with oauth2_user_id=NULL. On first login,
56
+ the user is matched by email and oauth2_user_id is populated (if unique_id
57
+ is configured). Not supported in null email marker mode.
58
+ """
59
+ unique_id = user_info.unique_id # Required when email is None
60
+
61
+ # Determine the email to use for lookup and storage
62
+ # If user_info.email is None, we're in null email marker mode
63
+ email: str | None = sanitize_email(user_info.email) if user_info.email else None
64
+
65
+ # Step 1: Look up user
66
+ # Strategy depends on whether unique_id is configured
67
+ user: models.User | None = None
68
+
69
+ if unique_id:
70
+ # Enterprise mode (or null email marker mode): lookup by unique_id first
71
+ user = await _lookup_by_unique_id(session, unique_id)
72
+
73
+ # Fallback: email lookup (handles migration to unique_id)
74
+ # Skip this in null email marker mode (no real email to look up)
75
+ if not user and email:
76
+ user = await _lookup_by_email(session, email)
77
+ if user:
78
+ # SECURITY: Only migrate if user has no existing unique_id.
79
+ # This prevents an email recycling attack where a new user with
80
+ # a recycled email address could hijack an old user's account.
81
+ #
82
+ # Scenario without this check:
83
+ # 1. User A leaves company (DB: email=john@corp.com, uuid=UUID-A)
84
+ # 2. User B joins with recycled email (LDAP: email=john@corp.com, uuid=UUID-B)
85
+ # 3. User B logs in, email lookup finds User A, UUID-B overwrites UUID-A
86
+ # 4. User B now has access to User A's data!
87
+ #
88
+ # With this check:
89
+ # - User A already has uuid=UUID-A, so no migration happens
90
+ # - User B is rejected (403) - admin must resolve the conflict
91
+ # - Note: We can't create a new user because email is unique in DB
92
+ if user.oauth2_user_id is None:
93
+ user.oauth2_user_id = unique_id
94
+ elif user.oauth2_user_id.lower() != unique_id.lower():
95
+ # Email matches but unique_id differs - this is a DIFFERENT person
96
+ # (e.g., email recycled to new employee).
97
+ #
98
+ # We cannot create a new user because email is unique in the database.
99
+ # This requires admin intervention to resolve (e.g., delete/rename the
100
+ # old account, or update the old account's unique_id).
101
+ logger.error(
102
+ f"LDAP account conflict: user_id={user.id} has different unique_id. "
103
+ f"Admin must resolve (delete old account or update unique_id)."
104
+ )
105
+ raise HTTPException(
106
+ status_code=401,
107
+ detail="Invalid username and/or password",
108
+ )
109
+ else:
110
+ # Same unique_id (case-insensitive match) - normalize case in DB
111
+ if user.oauth2_user_id != unique_id:
112
+ user.oauth2_user_id = unique_id
113
+ elif email:
114
+ # Simple mode: lookup by email only (oauth2_user_id is NULL)
115
+ user = await _lookup_by_email(session, email)
116
+ # else: neither unique_id nor email - this shouldn't happen (config validation prevents it)
117
+
118
+ # Step 2: Validate role exists
119
+ role = await session.scalar(
120
+ select(models.UserRole).where(models.UserRole.name == user_info.role)
121
+ )
122
+ if not role:
123
+ raise HTTPException(
124
+ status_code=500,
125
+ detail="Role not found in database",
126
+ )
127
+
128
+ # Step 3: Update existing user attributes
129
+ if user:
130
+ # Sync email on every login (email may have changed in LDAP)
131
+ if email and user.email != email:
132
+ user.email = email
133
+
134
+ # Note: Do NOT sync username - it should remain stable
135
+ # Updating username could cause collisions if displayName changes in LDAP
136
+
137
+ # Update role if it changed
138
+ if user.role.name != role.name:
139
+ user.role = role
140
+ return user
141
+
142
+ # Step 4: Create new user (if sign-up is allowed)
143
+ if not ldap_config.allow_sign_up:
144
+ logger.info("LDAP user attempted to sign up but sign-up is not allowed")
145
+ raise HTTPException(
146
+ status_code=401,
147
+ detail="Invalid username and/or password",
148
+ )
149
+
150
+ # Determine the email to store in the database
151
+ if email:
152
+ db_email = email
153
+ # Security: Check if email already exists with different auth method
154
+ existing_user = await session.scalar(
155
+ select(models.User).where(func.lower(models.User.email) == email.lower())
156
+ )
157
+ if existing_user and not is_ldap_user(existing_user.oauth2_client_id):
158
+ logger.error(
159
+ "Email already exists with different auth method: %s", existing_user.auth_method
160
+ )
161
+ raise HTTPException(
162
+ status_code=401,
163
+ detail="Invalid username and/or password",
164
+ )
165
+ else:
166
+ # Null email marker mode: generate deterministic marker from unique_id
167
+ # unique_id is guaranteed to be set (config validation ensures this)
168
+ if not unique_id:
169
+ raise ValueError("unique_id required when email is None")
170
+ db_email = generate_null_email_marker(unique_id)
171
+
172
+ # Username strategy: Try displayName first (user-friendly), handle collisions gracefully
173
+ username = user_info.display_name
174
+ existing_username = await session.scalar(
175
+ select(models.User).where(models.User.username == username)
176
+ )
177
+ if existing_username:
178
+ # Collision detected - append short suffix to make unique
179
+ username = f"{user_info.display_name} ({secrets.token_hex(3)})"
180
+
181
+ user = models.User(
182
+ email=db_email,
183
+ username=username,
184
+ role=role,
185
+ reset_password=False,
186
+ auth_method="OAUTH2", # TODO: change to LDAP in future db migration
187
+ oauth2_client_id=LDAP_CLIENT_ID_MARKER,
188
+ oauth2_user_id=unique_id, # None if unique_id not configured (use email column)
189
+ )
190
+ session.add(user)
191
+ return user
192
+
193
+
194
+ async def _lookup_by_unique_id(session: AsyncSession, unique_id: str) -> models.User | None:
195
+ """Look up LDAP user by immutable unique ID (objectGUID, entryUUID, etc.).
196
+
197
+ Uses case-insensitive comparison because:
198
+ - UUIDs are case-insensitive per RFC 4122
199
+ - Older versions may have stored uppercase UUIDs
200
+ - Current code normalizes to lowercase
201
+
202
+ This ensures users aren't locked out due to case differences.
203
+ """
204
+ return cast(
205
+ models.User | None,
206
+ await session.scalar(
207
+ select(models.User)
208
+ .where(models.User.oauth2_client_id == LDAP_CLIENT_ID_MARKER)
209
+ .where(func.lower(models.User.oauth2_user_id) == unique_id.lower())
210
+ .options(joinedload(models.User.role))
211
+ ),
212
+ )
213
+
214
+
215
+ async def _lookup_by_email(session: AsyncSession, email: str) -> models.User | None:
216
+ """Look up LDAP user by email (case-insensitive).
217
+
218
+ Note: Both sides of the comparison are lowercased to ensure consistent
219
+ matching regardless of what sanitize_email() does to the input.
220
+ """
221
+ return cast(
222
+ models.User | None,
223
+ await session.scalar(
224
+ select(models.User)
225
+ .where(models.User.oauth2_client_id == LDAP_CLIENT_ID_MARKER)
226
+ .where(func.lower(models.User.email) == email.lower())
227
+ .options(joinedload(models.User.role))
228
+ ),
229
+ )