arize-phoenix 10.0.4__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 (276) hide show
  1. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +124 -72
  2. arize_phoenix-12.28.1.dist-info/RECORD +499 -0
  3. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
  4. {arize_phoenix-10.0.4.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 +5 -4
  12. phoenix/auth.py +39 -2
  13. phoenix/config.py +1763 -91
  14. phoenix/datetime_utils.py +120 -2
  15. phoenix/db/README.md +595 -25
  16. phoenix/db/bulk_inserter.py +145 -103
  17. phoenix/db/engines.py +140 -33
  18. phoenix/db/enums.py +3 -12
  19. phoenix/db/facilitator.py +302 -35
  20. phoenix/db/helpers.py +1000 -65
  21. phoenix/db/iam_auth.py +64 -0
  22. phoenix/db/insertion/dataset.py +135 -2
  23. phoenix/db/insertion/document_annotation.py +9 -6
  24. phoenix/db/insertion/evaluation.py +2 -3
  25. phoenix/db/insertion/helpers.py +17 -2
  26. phoenix/db/insertion/session_annotation.py +176 -0
  27. phoenix/db/insertion/span.py +15 -11
  28. phoenix/db/insertion/span_annotation.py +3 -4
  29. phoenix/db/insertion/trace_annotation.py +3 -4
  30. phoenix/db/insertion/types.py +50 -20
  31. phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
  32. phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
  33. phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
  34. phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
  35. phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
  36. phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
  37. phoenix/db/migrations/versions/a20694b15f82_cost.py +196 -0
  38. phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
  39. phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
  40. phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
  41. phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
  42. phoenix/db/models.py +669 -56
  43. phoenix/db/pg_config.py +10 -0
  44. phoenix/db/types/model_provider.py +4 -0
  45. phoenix/db/types/token_price_customization.py +29 -0
  46. phoenix/db/types/trace_retention.py +23 -15
  47. phoenix/experiments/evaluators/utils.py +3 -3
  48. phoenix/experiments/functions.py +160 -52
  49. phoenix/experiments/tracing.py +2 -2
  50. phoenix/experiments/types.py +1 -1
  51. phoenix/inferences/inferences.py +1 -2
  52. phoenix/server/api/auth.py +38 -7
  53. phoenix/server/api/auth_messages.py +46 -0
  54. phoenix/server/api/context.py +100 -4
  55. phoenix/server/api/dataloaders/__init__.py +79 -5
  56. phoenix/server/api/dataloaders/annotation_configs_by_project.py +31 -0
  57. phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
  58. phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
  59. phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
  60. phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
  61. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  62. phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
  63. phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
  64. phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
  65. phoenix/server/api/dataloaders/dataset_labels.py +36 -0
  66. phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
  67. phoenix/server/api/dataloaders/document_evaluations.py +6 -9
  68. phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
  69. phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
  70. phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
  71. phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
  72. phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
  73. phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
  74. phoenix/server/api/dataloaders/last_used_times_by_generative_model_id.py +35 -0
  75. phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
  76. phoenix/server/api/dataloaders/record_counts.py +37 -10
  77. phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
  78. phoenix/server/api/dataloaders/span_cost_by_span.py +24 -0
  79. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_generative_model.py +56 -0
  80. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_project_session.py +57 -0
  81. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_span.py +43 -0
  82. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_trace.py +56 -0
  83. phoenix/server/api/dataloaders/span_cost_details_by_span_cost.py +27 -0
  84. phoenix/server/api/dataloaders/span_cost_summary_by_experiment.py +57 -0
  85. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
  86. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_run.py +58 -0
  87. phoenix/server/api/dataloaders/span_cost_summary_by_generative_model.py +55 -0
  88. phoenix/server/api/dataloaders/span_cost_summary_by_project.py +152 -0
  89. phoenix/server/api/dataloaders/span_cost_summary_by_project_session.py +56 -0
  90. phoenix/server/api/dataloaders/span_cost_summary_by_trace.py +55 -0
  91. phoenix/server/api/dataloaders/span_costs.py +29 -0
  92. phoenix/server/api/dataloaders/table_fields.py +2 -2
  93. phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
  94. phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
  95. phoenix/server/api/dataloaders/types.py +29 -0
  96. phoenix/server/api/exceptions.py +11 -1
  97. phoenix/server/api/helpers/dataset_helpers.py +5 -1
  98. phoenix/server/api/helpers/playground_clients.py +1243 -292
  99. phoenix/server/api/helpers/playground_registry.py +2 -2
  100. phoenix/server/api/helpers/playground_spans.py +8 -4
  101. phoenix/server/api/helpers/playground_users.py +26 -0
  102. phoenix/server/api/helpers/prompts/conversions/aws.py +83 -0
  103. phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
  104. phoenix/server/api/helpers/prompts/models.py +205 -22
  105. phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
  106. phoenix/server/api/input_types/ChatCompletionInput.py +6 -2
  107. phoenix/server/api/input_types/CreateProjectInput.py +27 -0
  108. phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
  109. phoenix/server/api/input_types/DatasetFilter.py +17 -0
  110. phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
  111. phoenix/server/api/input_types/GenerativeCredentialInput.py +9 -0
  112. phoenix/server/api/input_types/GenerativeModelInput.py +5 -0
  113. phoenix/server/api/input_types/ProjectSessionSort.py +161 -1
  114. phoenix/server/api/input_types/PromptFilter.py +14 -0
  115. phoenix/server/api/input_types/PromptVersionInput.py +52 -1
  116. phoenix/server/api/input_types/SpanSort.py +44 -7
  117. phoenix/server/api/input_types/TimeBinConfig.py +23 -0
  118. phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
  119. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  120. phoenix/server/api/mutations/__init__.py +10 -0
  121. phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
  122. phoenix/server/api/mutations/api_key_mutations.py +19 -23
  123. phoenix/server/api/mutations/chat_mutations.py +154 -47
  124. phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
  125. phoenix/server/api/mutations/dataset_mutations.py +21 -16
  126. phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
  127. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  128. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  129. phoenix/server/api/mutations/model_mutations.py +210 -0
  130. phoenix/server/api/mutations/project_mutations.py +49 -10
  131. phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
  132. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  133. phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
  134. phoenix/server/api/mutations/prompt_mutations.py +65 -129
  135. phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
  136. phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
  137. phoenix/server/api/mutations/trace_annotations_mutations.py +14 -10
  138. phoenix/server/api/mutations/trace_mutations.py +47 -3
  139. phoenix/server/api/mutations/user_mutations.py +66 -41
  140. phoenix/server/api/queries.py +768 -293
  141. phoenix/server/api/routers/__init__.py +2 -2
  142. phoenix/server/api/routers/auth.py +154 -88
  143. phoenix/server/api/routers/ldap.py +229 -0
  144. phoenix/server/api/routers/oauth2.py +369 -106
  145. phoenix/server/api/routers/v1/__init__.py +24 -4
  146. phoenix/server/api/routers/v1/annotation_configs.py +23 -31
  147. phoenix/server/api/routers/v1/annotations.py +481 -17
  148. phoenix/server/api/routers/v1/datasets.py +395 -81
  149. phoenix/server/api/routers/v1/documents.py +142 -0
  150. phoenix/server/api/routers/v1/evaluations.py +24 -31
  151. phoenix/server/api/routers/v1/experiment_evaluations.py +19 -8
  152. phoenix/server/api/routers/v1/experiment_runs.py +337 -59
  153. phoenix/server/api/routers/v1/experiments.py +479 -48
  154. phoenix/server/api/routers/v1/models.py +7 -0
  155. phoenix/server/api/routers/v1/projects.py +18 -49
  156. phoenix/server/api/routers/v1/prompts.py +54 -40
  157. phoenix/server/api/routers/v1/sessions.py +108 -0
  158. phoenix/server/api/routers/v1/spans.py +1091 -81
  159. phoenix/server/api/routers/v1/traces.py +132 -78
  160. phoenix/server/api/routers/v1/users.py +389 -0
  161. phoenix/server/api/routers/v1/utils.py +3 -7
  162. phoenix/server/api/subscriptions.py +305 -88
  163. phoenix/server/api/types/Annotation.py +90 -23
  164. phoenix/server/api/types/ApiKey.py +13 -17
  165. phoenix/server/api/types/AuthMethod.py +1 -0
  166. phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
  167. phoenix/server/api/types/CostBreakdown.py +12 -0
  168. phoenix/server/api/types/Dataset.py +226 -72
  169. phoenix/server/api/types/DatasetExample.py +88 -18
  170. phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
  171. phoenix/server/api/types/DatasetLabel.py +57 -0
  172. phoenix/server/api/types/DatasetSplit.py +98 -0
  173. phoenix/server/api/types/DatasetVersion.py +49 -4
  174. phoenix/server/api/types/DocumentAnnotation.py +212 -0
  175. phoenix/server/api/types/Experiment.py +264 -59
  176. phoenix/server/api/types/ExperimentComparison.py +5 -10
  177. phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
  178. phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
  179. phoenix/server/api/types/ExperimentRun.py +169 -65
  180. phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
  181. phoenix/server/api/types/GenerativeModel.py +245 -3
  182. phoenix/server/api/types/GenerativeProvider.py +70 -11
  183. phoenix/server/api/types/{Model.py → InferenceModel.py} +1 -1
  184. phoenix/server/api/types/ModelInterface.py +16 -0
  185. phoenix/server/api/types/PlaygroundModel.py +20 -0
  186. phoenix/server/api/types/Project.py +1278 -216
  187. phoenix/server/api/types/ProjectSession.py +188 -28
  188. phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
  189. phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
  190. phoenix/server/api/types/Prompt.py +119 -39
  191. phoenix/server/api/types/PromptLabel.py +42 -25
  192. phoenix/server/api/types/PromptVersion.py +11 -8
  193. phoenix/server/api/types/PromptVersionTag.py +65 -25
  194. phoenix/server/api/types/ServerStatus.py +6 -0
  195. phoenix/server/api/types/Span.py +167 -123
  196. phoenix/server/api/types/SpanAnnotation.py +189 -42
  197. phoenix/server/api/types/SpanCostDetailSummaryEntry.py +10 -0
  198. phoenix/server/api/types/SpanCostSummary.py +10 -0
  199. phoenix/server/api/types/SystemApiKey.py +65 -1
  200. phoenix/server/api/types/TokenPrice.py +16 -0
  201. phoenix/server/api/types/TokenUsage.py +3 -3
  202. phoenix/server/api/types/Trace.py +223 -51
  203. phoenix/server/api/types/TraceAnnotation.py +149 -50
  204. phoenix/server/api/types/User.py +137 -32
  205. phoenix/server/api/types/UserApiKey.py +73 -26
  206. phoenix/server/api/types/node.py +10 -0
  207. phoenix/server/api/types/pagination.py +11 -2
  208. phoenix/server/app.py +290 -45
  209. phoenix/server/authorization.py +38 -3
  210. phoenix/server/bearer_auth.py +34 -24
  211. phoenix/server/cost_tracking/cost_details_calculator.py +196 -0
  212. phoenix/server/cost_tracking/cost_model_lookup.py +179 -0
  213. phoenix/server/cost_tracking/helpers.py +68 -0
  214. phoenix/server/cost_tracking/model_cost_manifest.json +3657 -830
  215. phoenix/server/cost_tracking/regex_specificity.py +397 -0
  216. phoenix/server/cost_tracking/token_cost_calculator.py +57 -0
  217. phoenix/server/daemons/__init__.py +0 -0
  218. phoenix/server/daemons/db_disk_usage_monitor.py +214 -0
  219. phoenix/server/daemons/generative_model_store.py +103 -0
  220. phoenix/server/daemons/span_cost_calculator.py +99 -0
  221. phoenix/server/dml_event.py +17 -0
  222. phoenix/server/dml_event_handler.py +5 -0
  223. phoenix/server/email/sender.py +56 -3
  224. phoenix/server/email/templates/db_disk_usage_notification.html +19 -0
  225. phoenix/server/email/types.py +11 -0
  226. phoenix/server/experiments/__init__.py +0 -0
  227. phoenix/server/experiments/utils.py +14 -0
  228. phoenix/server/grpc_server.py +11 -11
  229. phoenix/server/jwt_store.py +17 -15
  230. phoenix/server/ldap.py +1449 -0
  231. phoenix/server/main.py +26 -10
  232. phoenix/server/oauth2.py +330 -12
  233. phoenix/server/prometheus.py +66 -6
  234. phoenix/server/rate_limiters.py +4 -9
  235. phoenix/server/retention.py +33 -20
  236. phoenix/server/session_filters.py +49 -0
  237. phoenix/server/static/.vite/manifest.json +55 -51
  238. phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
  239. phoenix/server/static/assets/{index-E0M82BdE.js → index-CTQoemZv.js} +140 -56
  240. phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
  241. phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
  242. phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
  243. phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
  244. phoenix/server/static/assets/vendor-recharts-V9cwpXsm.js +37 -0
  245. phoenix/server/static/assets/vendor-shiki-Do--csgv.js +5 -0
  246. phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
  247. phoenix/server/templates/index.html +40 -6
  248. phoenix/server/thread_server.py +1 -2
  249. phoenix/server/types.py +14 -4
  250. phoenix/server/utils.py +74 -0
  251. phoenix/session/client.py +56 -3
  252. phoenix/session/data_extractor.py +5 -0
  253. phoenix/session/evaluation.py +14 -5
  254. phoenix/session/session.py +45 -9
  255. phoenix/settings.py +5 -0
  256. phoenix/trace/attributes.py +80 -13
  257. phoenix/trace/dsl/helpers.py +90 -1
  258. phoenix/trace/dsl/query.py +8 -6
  259. phoenix/trace/projects.py +5 -0
  260. phoenix/utilities/template_formatters.py +1 -1
  261. phoenix/version.py +1 -1
  262. arize_phoenix-10.0.4.dist-info/RECORD +0 -405
  263. phoenix/server/api/types/Evaluation.py +0 -39
  264. phoenix/server/cost_tracking/cost_lookup.py +0 -255
  265. phoenix/server/static/assets/components-DULKeDfL.js +0 -4365
  266. phoenix/server/static/assets/pages-Cl0A-0U2.js +0 -7430
  267. phoenix/server/static/assets/vendor-WIZid84E.css +0 -1
  268. phoenix/server/static/assets/vendor-arizeai-Dy-0mSNw.js +0 -649
  269. phoenix/server/static/assets/vendor-codemirror-DBtifKNr.js +0 -33
  270. phoenix/server/static/assets/vendor-oB4u9zuV.js +0 -905
  271. phoenix/server/static/assets/vendor-recharts-D-T4KPz2.js +0 -59
  272. phoenix/server/static/assets/vendor-shiki-BMn4O_9F.js +0 -5
  273. phoenix/server/static/assets/vendor-three-C5WAXd5r.js +0 -2998
  274. phoenix/utilities/deprecation.py +0 -31
  275. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
  276. {arize_phoenix-10.0.4.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,21 +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
- from sqlalchemy import select
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_401_UNAUTHORIZED,
14
- HTTP_403_FORBIDDEN,
15
- HTTP_404_NOT_FOUND,
16
- HTTP_422_UNPROCESSABLE_ENTITY,
17
- HTTP_503_SERVICE_UNAVAILABLE,
18
- )
19
12
 
20
13
  from phoenix.auth import (
21
14
  DEFAULT_SECRET_LENGTH,
@@ -28,6 +21,7 @@ from phoenix.auth import (
28
21
  delete_oauth2_state_cookie,
29
22
  delete_refresh_token_cookie,
30
23
  is_valid_password,
24
+ sanitize_email,
31
25
  set_access_token_cookie,
32
26
  set_refresh_token_cookie,
33
27
  validate_password_format,
@@ -36,9 +30,9 @@ from phoenix.config import (
36
30
  get_base_url,
37
31
  get_env_disable_basic_auth,
38
32
  get_env_disable_rate_limit,
39
- get_env_host_root_path,
40
33
  )
41
34
  from phoenix.db import models
35
+ from phoenix.server.api.routers.ldap import get_or_create_ldap_user
42
36
  from phoenix.server.bearer_auth import PhoenixUser, create_access_and_refresh_tokens
43
37
  from phoenix.server.email.types import EmailSender
44
38
  from phoenix.server.rate_limiters import ServerRateLimiter, fastapi_ip_rate_limiter
@@ -50,6 +44,12 @@ from phoenix.server.types import (
50
44
  TokenStore,
51
45
  UserId,
52
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__)
53
53
 
54
54
  rate_limiter = ServerRateLimiter(
55
55
  per_second_rate_limit=0.2,
@@ -57,73 +57,92 @@ rate_limiter = ServerRateLimiter(
57
57
  partition_seconds=60,
58
58
  active_partitions=2,
59
59
  )
60
- login_rate_limiter = fastapi_ip_rate_limiter(
61
- rate_limiter,
62
- 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 = [
63
79
  "/auth/login",
64
80
  "/auth/logout",
65
81
  "/auth/refresh",
66
82
  "/auth/password-reset-email",
67
83
  "/auth/password-reset",
68
- ],
69
- )
84
+ ]
85
+ if ldap_enabled:
86
+ rate_limited_paths.append("/auth/ldap/login")
87
+
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 []
70
90
 
71
- auth_dependencies = [Depends(login_rate_limiter)] if not get_env_disable_rate_limit() else []
72
- router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_dependencies)
91
+ router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_dependencies)
73
92
 
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"])
74
99
 
75
- @router.post("/login")
76
- async def login(request: Request) -> Response:
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."""
77
109
  if get_env_disable_basic_auth():
78
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
79
- assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
80
- assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
81
- token_store: TokenStore = request.app.state.get_token_store()
110
+ raise HTTPException(status_code=403)
82
111
  data = await request.json()
83
112
  email = data.get("email")
84
113
  password = data.get("password")
85
114
 
86
115
  if not email or not password:
87
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Email and password required")
116
+ raise HTTPException(status_code=401, detail="Email and password required")
117
+
118
+ # Sanitize email by trimming and lowercasing
119
+ email = sanitize_email(email)
88
120
 
89
121
  async with request.app.state.db() as session:
90
122
  user = await session.scalar(
91
- select(models.User).filter_by(email=email).options(joinedload(models.User.role))
123
+ select(models.User)
124
+ .where(func.lower(models.User.email) == email)
125
+ .options(joinedload(models.User.role))
92
126
  )
93
127
  if (
94
128
  user is None
95
129
  or (password_hash := user.password_hash) is None
96
130
  or (salt := user.password_salt) is None
97
131
  ):
98
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
132
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
99
133
 
100
134
  loop = asyncio.get_running_loop()
101
135
  password_is_valid = partial(
102
136
  is_valid_password, password=password, salt=salt, password_hash=password_hash
103
137
  )
104
138
  if not await loop.run_in_executor(None, password_is_valid):
105
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
139
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
106
140
 
107
- access_token, refresh_token = await create_access_and_refresh_tokens(
108
- token_store=token_store,
109
- user=user,
110
- access_token_expiry=access_token_expiry,
111
- refresh_token_expiry=refresh_token_expiry,
112
- )
113
- response = Response(status_code=HTTP_204_NO_CONTENT)
114
- response = set_access_token_cookie(
115
- response=response, access_token=access_token, max_age=access_token_expiry
116
- )
117
- response = set_refresh_token_cookie(
118
- response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
119
- )
120
- return response
141
+ return await _create_auth_response(request, user)
121
142
 
122
143
 
123
- @router.post("/logout")
124
- async def logout(
125
- request: Request,
126
- ) -> Response:
144
+ async def _logout(request: Request) -> Response:
145
+ """Log out user by revoking tokens and clearing cookies."""
127
146
  token_store: TokenStore = request.app.state.get_token_store()
128
147
  user_id = None
129
148
  if isinstance(user := request.user, PhoenixUser):
@@ -138,7 +157,9 @@ async def logout(
138
157
  user_id = subject
139
158
  if user_id:
140
159
  await token_store.log_out(user_id)
141
- response = Response(status_code=HTTP_204_NO_CONTENT)
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})
142
163
  response = delete_access_token_cookie(response)
143
164
  response = delete_refresh_token_cookie(response)
144
165
  response = delete_oauth2_state_cookie(response)
@@ -146,12 +167,10 @@ async def logout(
146
167
  return response
147
168
 
148
169
 
149
- @router.post("/refresh")
150
- async def refresh_tokens(request: Request) -> Response:
151
- assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
152
- 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."""
153
172
  if (refresh_token := request.cookies.get(PHOENIX_REFRESH_TOKEN_COOKIE_NAME)) is None:
154
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
173
+ raise HTTPException(status_code=401, detail="Missing refresh token")
155
174
  token_store: TokenStore = request.app.state.get_token_store()
156
175
  refresh_token_claims = await token_store.read(Token(refresh_token))
157
176
  if (
@@ -161,9 +180,9 @@ async def refresh_tokens(request: Request) -> Response:
161
180
  or (user_id := int(refresh_token_claims.subject)) is None
162
181
  or (expiration_time := refresh_token_claims.expiration_time) is None
163
182
  ):
164
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
165
- if expiration_time.timestamp() < datetime.now().timestamp():
166
- 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")
167
186
  await token_store.revoke(refresh_token_id)
168
187
 
169
188
  if (
@@ -181,30 +200,22 @@ async def refresh_tokens(request: Request) -> Response:
181
200
  select(models.User).filter_by(id=user_id).options(joinedload(models.User.role))
182
201
  )
183
202
  ) is None:
184
- raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found")
185
- access_token, refresh_token = await create_access_and_refresh_tokens(
186
- token_store=token_store,
187
- user=user,
188
- access_token_expiry=access_token_expiry,
189
- refresh_token_expiry=refresh_token_expiry,
190
- )
191
- response = Response(status_code=HTTP_204_NO_CONTENT)
192
- response = set_access_token_cookie(
193
- response=response, access_token=access_token, max_age=access_token_expiry
194
- )
195
- response = set_refresh_token_cookie(
196
- response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
197
- )
198
- return response
203
+ raise HTTPException(status_code=404, detail="User not found")
199
204
 
205
+ return await _create_auth_response(request, user)
200
206
 
201
- @router.post("/password-reset-email")
202
- async def initiate_password_reset(request: Request) -> Response:
207
+
208
+ async def _initiate_password_reset(request: Request) -> Response:
209
+ """Send password reset email to user."""
203
210
  if get_env_disable_basic_auth():
204
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
211
+ raise HTTPException(status_code=403)
205
212
  data = await request.json()
206
213
  if not (email := data.get("email")):
207
214
  raise MISSING_EMAIL
215
+
216
+ # Sanitize email by trimming and lowercasing
217
+ email = sanitize_email(email)
218
+
208
219
  sender: EmailSender = request.app.state.email_sender
209
220
  if sender is None:
210
221
  raise SMTP_UNAVAILABLE
@@ -212,14 +223,14 @@ async def initiate_password_reset(request: Request) -> Response:
212
223
  async with request.app.state.db() as session:
213
224
  user = await session.scalar(
214
225
  select(models.User)
215
- .filter_by(email=email)
226
+ .where(func.lower(models.User.email) == email)
216
227
  .options(
217
228
  joinedload(models.User.password_reset_token).load_only(models.PasswordResetToken.id)
218
229
  )
219
230
  )
220
231
  if user is None or user.auth_method != "LOCAL":
221
232
  # Withold privileged information
222
- return Response(status_code=HTTP_204_NO_CONTENT)
233
+ return Response(status_code=204)
223
234
  token_store: TokenStore = request.app.state.get_token_store()
224
235
  if user.password_reset_token:
225
236
  await token_store.revoke(PasswordResetTokenId(user.password_reset_token.id))
@@ -230,18 +241,18 @@ async def initiate_password_reset(request: Request) -> Response:
230
241
  )
231
242
  token, _ = await token_store.create_password_reset_token(password_reset_token_claims)
232
243
  url = urlparse(request.headers.get("referer") or get_base_url())
233
- path = Path(get_env_host_root_path()) / "reset-password-with-token"
244
+ path = prepend_root_path(request.scope, "/reset-password-with-token")
234
245
  query_string = urlencode(dict(token=token))
235
- components = (url.scheme, url.netloc, path.as_posix(), "", query_string, "")
246
+ components = (url.scheme, url.netloc, path, "", query_string, "")
236
247
  reset_url = urlunparse(components)
237
248
  await sender.send_password_reset_email(email, reset_url)
238
- return Response(status_code=HTTP_204_NO_CONTENT)
249
+ return Response(status_code=204)
239
250
 
240
251
 
241
- @router.post("/password-reset")
242
- async def reset_password(request: Request) -> Response:
252
+ async def _reset_password(request: Request) -> Response:
253
+ """Reset user password using a valid reset token."""
243
254
  if get_env_disable_basic_auth():
244
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
255
+ raise HTTPException(status_code=403)
245
256
  data = await request.json()
246
257
  if not (password := data.get("password")):
247
258
  raise MISSING_PASSWORD
@@ -250,7 +261,7 @@ async def reset_password(request: Request) -> Response:
250
261
  not (token := data.get("token"))
251
262
  or not isinstance((claims := await token_store.read(token)), PasswordResetTokenClaims)
252
263
  or not claims.expiration_time
253
- or claims.expiration_time < datetime.now(timezone.utc)
264
+ or claims.expiration_time <= datetime.now(timezone.utc)
254
265
  ):
255
266
  raise INVALID_TOKEN
256
267
  assert (user_id := claims.subject)
@@ -258,7 +269,7 @@ async def reset_password(request: Request) -> Response:
258
269
  user = await session.scalar(select(models.User).filter_by(id=int(user_id)))
259
270
  if user is None or user.auth_method != "LOCAL":
260
271
  # Withold privileged information
261
- return Response(status_code=HTTP_204_NO_CONTENT)
272
+ return Response(status_code=204)
262
273
  validate_password_format(password)
263
274
  user.password_salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
264
275
  loop = asyncio.get_running_loop()
@@ -269,28 +280,83 @@ async def reset_password(request: Request) -> Response:
269
280
  async with request.app.state.db() as session:
270
281
  session.add(user)
271
282
  await session.flush()
272
- response = Response(status_code=HTTP_204_NO_CONTENT)
283
+ response = Response(status_code=204)
273
284
  assert (token_id := claims.token_id)
274
285
  await token_store.revoke(token_id)
275
286
  await token_store.log_out(UserId(user.id))
276
287
  return response
277
288
 
278
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
+
279
345
  LOGIN_FAILED_MESSAGE = "Invalid email and/or password"
280
346
 
281
347
  MISSING_EMAIL = HTTPException(
282
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
348
+ status_code=422,
283
349
  detail="Email required",
284
350
  )
285
351
  MISSING_PASSWORD = HTTPException(
286
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
352
+ status_code=422,
287
353
  detail="Password required",
288
354
  )
289
355
  SMTP_UNAVAILABLE = HTTPException(
290
- status_code=HTTP_503_SERVICE_UNAVAILABLE,
356
+ status_code=503,
291
357
  detail="SMTP server not configured",
292
358
  )
293
359
  INVALID_TOKEN = HTTPException(
294
- status_code=HTTP_401_UNAUTHORIZED,
360
+ status_code=401,
295
361
  detail="Invalid token",
296
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
+ )