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.
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +61 -36
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/RECORD +212 -162
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/IP_NOTICE +1 -1
- phoenix/__generated__/__init__.py +0 -0
- phoenix/__generated__/classification_evaluator_configs/__init__.py +20 -0
- phoenix/__generated__/classification_evaluator_configs/_document_relevance_classification_evaluator_config.py +17 -0
- phoenix/__generated__/classification_evaluator_configs/_hallucination_classification_evaluator_config.py +17 -0
- phoenix/__generated__/classification_evaluator_configs/_models.py +18 -0
- phoenix/__generated__/classification_evaluator_configs/_tool_selection_classification_evaluator_config.py +17 -0
- phoenix/__init__.py +2 -1
- phoenix/auth.py +27 -2
- phoenix/config.py +1594 -81
- phoenix/db/README.md +546 -28
- phoenix/db/bulk_inserter.py +119 -116
- phoenix/db/engines.py +140 -33
- phoenix/db/facilitator.py +22 -1
- phoenix/db/helpers.py +818 -65
- phoenix/db/iam_auth.py +64 -0
- phoenix/db/insertion/dataset.py +133 -1
- phoenix/db/insertion/document_annotation.py +9 -6
- phoenix/db/insertion/evaluation.py +2 -3
- phoenix/db/insertion/helpers.py +2 -2
- phoenix/db/insertion/session_annotation.py +176 -0
- phoenix/db/insertion/span_annotation.py +3 -4
- phoenix/db/insertion/trace_annotation.py +3 -4
- phoenix/db/insertion/types.py +41 -18
- phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
- phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
- phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
- phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
- phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
- phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
- phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
- phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
- phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
- phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
- phoenix/db/models.py +364 -56
- phoenix/db/pg_config.py +10 -0
- phoenix/db/types/trace_retention.py +7 -6
- phoenix/experiments/functions.py +69 -19
- phoenix/inferences/inferences.py +1 -2
- phoenix/server/api/auth.py +9 -0
- phoenix/server/api/auth_messages.py +46 -0
- phoenix/server/api/context.py +60 -0
- phoenix/server/api/dataloaders/__init__.py +36 -0
- phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
- phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
- phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
- phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
- phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
- phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
- phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
- phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
- phoenix/server/api/dataloaders/dataset_labels.py +36 -0
- phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
- phoenix/server/api/dataloaders/document_evaluations.py +6 -9
- phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
- phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
- phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
- phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
- phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
- phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
- phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
- phoenix/server/api/dataloaders/record_counts.py +37 -10
- phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
- phoenix/server/api/dataloaders/span_cost_summary_by_project.py +28 -14
- phoenix/server/api/dataloaders/span_costs.py +3 -9
- phoenix/server/api/dataloaders/table_fields.py +2 -2
- phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
- phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
- phoenix/server/api/exceptions.py +5 -1
- phoenix/server/api/helpers/playground_clients.py +263 -83
- phoenix/server/api/helpers/playground_spans.py +2 -1
- phoenix/server/api/helpers/playground_users.py +26 -0
- phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
- phoenix/server/api/helpers/prompts/models.py +61 -19
- phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
- phoenix/server/api/input_types/ChatCompletionInput.py +3 -0
- phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
- phoenix/server/api/input_types/DatasetFilter.py +5 -2
- phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
- phoenix/server/api/input_types/GenerativeModelInput.py +3 -0
- phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
- phoenix/server/api/input_types/PromptVersionInput.py +47 -1
- phoenix/server/api/input_types/SpanSort.py +3 -2
- phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
- phoenix/server/api/input_types/UserRoleInput.py +1 -0
- phoenix/server/api/mutations/__init__.py +8 -0
- phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
- phoenix/server/api/mutations/api_key_mutations.py +15 -20
- phoenix/server/api/mutations/chat_mutations.py +106 -37
- phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
- phoenix/server/api/mutations/dataset_mutations.py +21 -16
- phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
- phoenix/server/api/mutations/experiment_mutations.py +2 -2
- phoenix/server/api/mutations/export_events_mutations.py +3 -3
- phoenix/server/api/mutations/model_mutations.py +11 -9
- phoenix/server/api/mutations/project_mutations.py +4 -4
- phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
- phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
- phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
- phoenix/server/api/mutations/prompt_mutations.py +65 -129
- phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
- phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
- phoenix/server/api/mutations/trace_annotations_mutations.py +13 -8
- phoenix/server/api/mutations/trace_mutations.py +3 -3
- phoenix/server/api/mutations/user_mutations.py +55 -26
- phoenix/server/api/queries.py +501 -617
- phoenix/server/api/routers/__init__.py +2 -2
- phoenix/server/api/routers/auth.py +141 -87
- phoenix/server/api/routers/ldap.py +229 -0
- phoenix/server/api/routers/oauth2.py +349 -101
- phoenix/server/api/routers/v1/__init__.py +22 -4
- phoenix/server/api/routers/v1/annotation_configs.py +19 -30
- phoenix/server/api/routers/v1/annotations.py +455 -13
- phoenix/server/api/routers/v1/datasets.py +355 -68
- phoenix/server/api/routers/v1/documents.py +142 -0
- phoenix/server/api/routers/v1/evaluations.py +20 -28
- phoenix/server/api/routers/v1/experiment_evaluations.py +16 -6
- phoenix/server/api/routers/v1/experiment_runs.py +335 -59
- phoenix/server/api/routers/v1/experiments.py +475 -47
- phoenix/server/api/routers/v1/projects.py +16 -50
- phoenix/server/api/routers/v1/prompts.py +50 -39
- phoenix/server/api/routers/v1/sessions.py +108 -0
- phoenix/server/api/routers/v1/spans.py +156 -96
- phoenix/server/api/routers/v1/traces.py +51 -77
- phoenix/server/api/routers/v1/users.py +64 -24
- phoenix/server/api/routers/v1/utils.py +3 -7
- phoenix/server/api/subscriptions.py +257 -93
- phoenix/server/api/types/Annotation.py +90 -23
- phoenix/server/api/types/ApiKey.py +13 -17
- phoenix/server/api/types/AuthMethod.py +1 -0
- phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
- phoenix/server/api/types/Dataset.py +199 -72
- phoenix/server/api/types/DatasetExample.py +88 -18
- phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
- phoenix/server/api/types/DatasetLabel.py +57 -0
- phoenix/server/api/types/DatasetSplit.py +98 -0
- phoenix/server/api/types/DatasetVersion.py +49 -4
- phoenix/server/api/types/DocumentAnnotation.py +212 -0
- phoenix/server/api/types/Experiment.py +215 -68
- phoenix/server/api/types/ExperimentComparison.py +3 -9
- phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
- phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
- phoenix/server/api/types/ExperimentRun.py +120 -70
- phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
- phoenix/server/api/types/GenerativeModel.py +95 -42
- phoenix/server/api/types/GenerativeProvider.py +1 -1
- phoenix/server/api/types/ModelInterface.py +7 -2
- phoenix/server/api/types/PlaygroundModel.py +12 -2
- phoenix/server/api/types/Project.py +218 -185
- phoenix/server/api/types/ProjectSession.py +146 -29
- phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
- phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
- phoenix/server/api/types/Prompt.py +119 -39
- phoenix/server/api/types/PromptLabel.py +42 -25
- phoenix/server/api/types/PromptVersion.py +11 -8
- phoenix/server/api/types/PromptVersionTag.py +65 -25
- phoenix/server/api/types/Span.py +130 -123
- phoenix/server/api/types/SpanAnnotation.py +189 -42
- phoenix/server/api/types/SystemApiKey.py +65 -1
- phoenix/server/api/types/Trace.py +184 -53
- phoenix/server/api/types/TraceAnnotation.py +149 -50
- phoenix/server/api/types/User.py +128 -33
- phoenix/server/api/types/UserApiKey.py +73 -26
- phoenix/server/api/types/node.py +10 -0
- phoenix/server/api/types/pagination.py +11 -2
- phoenix/server/app.py +154 -36
- phoenix/server/authorization.py +5 -4
- phoenix/server/bearer_auth.py +13 -5
- phoenix/server/cost_tracking/cost_model_lookup.py +42 -14
- phoenix/server/cost_tracking/model_cost_manifest.json +1085 -194
- phoenix/server/daemons/generative_model_store.py +61 -9
- phoenix/server/daemons/span_cost_calculator.py +10 -8
- phoenix/server/dml_event.py +13 -0
- phoenix/server/email/sender.py +29 -2
- phoenix/server/grpc_server.py +9 -9
- phoenix/server/jwt_store.py +8 -6
- phoenix/server/ldap.py +1449 -0
- phoenix/server/main.py +9 -3
- phoenix/server/oauth2.py +330 -12
- phoenix/server/prometheus.py +43 -6
- phoenix/server/rate_limiters.py +4 -9
- phoenix/server/retention.py +33 -20
- phoenix/server/session_filters.py +49 -0
- phoenix/server/static/.vite/manifest.json +51 -53
- phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
- phoenix/server/static/assets/{index-BPCwGQr8.js → index-CTQoemZv.js} +42 -35
- phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
- phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
- phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
- phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
- phoenix/server/static/assets/{vendor-recharts-Bw30oz1A.js → vendor-recharts-V9cwpXsm.js} +7 -7
- phoenix/server/static/assets/{vendor-shiki-DZajAPeq.js → vendor-shiki-Do--csgv.js} +1 -1
- phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
- phoenix/server/templates/index.html +7 -1
- phoenix/server/thread_server.py +1 -2
- phoenix/server/utils.py +74 -0
- phoenix/session/client.py +55 -1
- phoenix/session/data_extractor.py +5 -0
- phoenix/session/evaluation.py +8 -4
- phoenix/session/session.py +44 -8
- phoenix/settings.py +2 -0
- phoenix/trace/attributes.py +80 -13
- phoenix/trace/dsl/query.py +2 -0
- phoenix/trace/projects.py +5 -0
- phoenix/utilities/template_formatters.py +1 -1
- phoenix/version.py +1 -1
- phoenix/server/api/types/Evaluation.py +0 -39
- phoenix/server/static/assets/components-D0DWAf0l.js +0 -5650
- phoenix/server/static/assets/pages-Creyamao.js +0 -8612
- phoenix/server/static/assets/vendor-CU36oj8y.js +0 -905
- phoenix/server/static/assets/vendor-CqDb5u4o.css +0 -1
- phoenix/server/static/assets/vendor-arizeai-Ctgw0e1G.js +0 -168
- phoenix/server/static/assets/vendor-codemirror-Cojjzqb9.js +0 -25
- phoenix/server/static/assets/vendor-three-BLWp5bic.js +0 -2998
- phoenix/utilities/deprecation.py +0 -31
- {arize_phoenix-11.23.1.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
139
|
+
raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
|
|
113
140
|
|
|
114
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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=
|
|
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=
|
|
173
|
-
if expiration_time.timestamp()
|
|
174
|
-
raise HTTPException(status_code=
|
|
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=
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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=
|
|
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=
|
|
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 =
|
|
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
|
|
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=
|
|
249
|
+
return Response(status_code=204)
|
|
251
250
|
|
|
252
251
|
|
|
253
|
-
|
|
254
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
348
|
+
status_code=422,
|
|
295
349
|
detail="Email required",
|
|
296
350
|
)
|
|
297
351
|
MISSING_PASSWORD = HTTPException(
|
|
298
|
-
status_code=
|
|
352
|
+
status_code=422,
|
|
299
353
|
detail="Password required",
|
|
300
354
|
)
|
|
301
355
|
SMTP_UNAVAILABLE = HTTPException(
|
|
302
|
-
status_code=
|
|
356
|
+
status_code=503,
|
|
303
357
|
detail="SMTP server not configured",
|
|
304
358
|
)
|
|
305
359
|
INVALID_TOKEN = HTTPException(
|
|
306
|
-
status_code=
|
|
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
|
+
)
|