remdb 0.3.14__py3-none-any.whl → 0.3.157__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.
- rem/agentic/README.md +76 -0
- rem/agentic/__init__.py +15 -0
- rem/agentic/agents/__init__.py +32 -2
- rem/agentic/agents/agent_manager.py +310 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +51 -27
- rem/agentic/context_builder.py +5 -3
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/tool_wrapper.py +155 -18
- rem/agentic/otel/setup.py +93 -4
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +280 -57
- rem/agentic/schema.py +361 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/README.md +215 -1
- rem/api/deps.py +255 -0
- rem/api/main.py +132 -40
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +28 -5
- rem/api/mcp_router/tools.py +555 -7
- rem/api/routers/admin.py +494 -0
- rem/api/routers/auth.py +278 -4
- rem/api/routers/chat/completions.py +402 -20
- rem/api/routers/chat/models.py +88 -10
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +542 -0
- rem/api/routers/chat/streaming.py +697 -45
- rem/api/routers/dev.py +81 -0
- rem/api/routers/feedback.py +268 -0
- rem/api/routers/messages.py +473 -0
- rem/api/routers/models.py +78 -0
- rem/api/routers/query.py +360 -0
- rem/api/routers/shared_sessions.py +406 -0
- rem/auth/__init__.py +13 -3
- rem/auth/middleware.py +186 -22
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +237 -64
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +386 -143
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/process.py +14 -8
- rem/cli/commands/schema.py +97 -50
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +29 -6
- rem/config.py +10 -3
- rem/models/core/core_model.py +7 -1
- rem/models/core/experiment.py +58 -14
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/__init__.py +25 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/message.py +30 -1
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/session.py +83 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/registry.py +10 -4
- rem/schemas/agents/core/agent-builder.yaml +134 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/schemas/agents/rem.yaml +7 -3
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +92 -19
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +459 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/api.py +4 -4
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/client.py +154 -14
- rem/services/postgres/README.md +197 -15
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +547 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +470 -140
- rem/services/postgres/repository.py +132 -0
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/postgres/service.py +6 -6
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +137 -51
- rem/services/session/reload.py +15 -8
- rem/settings.py +515 -27
- rem/sql/background_indexes.sql +21 -16
- rem/sql/migrations/001_install.sql +387 -54
- rem/sql/migrations/002_install_models.sql +2304 -377
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/date_utils.py +2 -2
- rem/utils/files.py +157 -1
- rem/utils/model_helpers.py +156 -1
- rem/utils/schema_loader.py +220 -22
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +3 -1
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/METADATA +340 -229
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/RECORD +109 -80
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/WHEEL +1 -1
- rem/sql/002_install_models.sql +0 -1068
- rem/sql/install_models.sql +0 -1051
- rem/sql/migrations/003_seed_default_user.sql +0 -48
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
rem/auth/middleware.py
CHANGED
|
@@ -2,14 +2,36 @@
|
|
|
2
2
|
OAuth Authentication Middleware for FastAPI.
|
|
3
3
|
|
|
4
4
|
Protects API endpoints by requiring valid session.
|
|
5
|
-
|
|
5
|
+
Supports anonymous access with rate limiting when allow_anonymous=True.
|
|
6
|
+
MCP endpoints are always protected unless explicitly disabled.
|
|
6
7
|
|
|
7
8
|
Design Pattern:
|
|
9
|
+
- Check X-API-Key header first (if API key auth enabled)
|
|
8
10
|
- Check session for user on protected paths
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
+
- Check Bearer token for dev token (non-production only)
|
|
12
|
+
- MCP paths always require authentication (protected service)
|
|
13
|
+
- If allow_anonymous=True: Allow unauthenticated requests (marked as ANONYMOUS tier)
|
|
14
|
+
- If allow_anonymous=False: Return 401 for API calls, redirect browsers to login
|
|
11
15
|
- Exclude auth endpoints and public paths
|
|
12
16
|
|
|
17
|
+
Access Modes (configured in settings.auth):
|
|
18
|
+
- enabled=true, allow_anonymous=true: Auth available, anonymous gets rate-limited access
|
|
19
|
+
- enabled=true, allow_anonymous=false: Auth required for all requests
|
|
20
|
+
- enabled=false: Middleware not loaded, all requests pass through
|
|
21
|
+
- mcp_requires_auth=true (default): MCP always requires login regardless of allow_anonymous
|
|
22
|
+
- mcp_requires_auth=false: MCP follows normal allow_anonymous rules (dev only)
|
|
23
|
+
|
|
24
|
+
API Key Authentication (configured in settings.api):
|
|
25
|
+
- api_key_enabled=true: Require X-API-Key header for protected endpoints
|
|
26
|
+
- api_key: The secret key to validate against
|
|
27
|
+
- Provides simple programmatic access without OAuth flow
|
|
28
|
+
- X-API-Key header takes precedence over session auth
|
|
29
|
+
|
|
30
|
+
Dev Token Support (non-production only):
|
|
31
|
+
- GET /api/auth/dev/token returns a Bearer token for test-user
|
|
32
|
+
- Include as: Authorization: Bearer dev_<signature>
|
|
33
|
+
- Only works when ENVIRONMENT != "production"
|
|
34
|
+
|
|
13
35
|
Usage:
|
|
14
36
|
from rem.auth.middleware import AuthMiddleware
|
|
15
37
|
|
|
@@ -17,6 +39,8 @@ Usage:
|
|
|
17
39
|
AuthMiddleware,
|
|
18
40
|
protected_paths=["/api/v1"],
|
|
19
41
|
excluded_paths=["/api/auth", "/health"],
|
|
42
|
+
allow_anonymous=settings.auth.allow_anonymous,
|
|
43
|
+
mcp_requires_auth=settings.auth.mcp_requires_auth,
|
|
20
44
|
)
|
|
21
45
|
"""
|
|
22
46
|
|
|
@@ -25,6 +49,8 @@ from starlette.requests import Request
|
|
|
25
49
|
from starlette.responses import JSONResponse, RedirectResponse
|
|
26
50
|
from loguru import logger
|
|
27
51
|
|
|
52
|
+
from ..settings import settings
|
|
53
|
+
|
|
28
54
|
|
|
29
55
|
class AuthMiddleware(BaseHTTPMiddleware):
|
|
30
56
|
"""
|
|
@@ -32,6 +58,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
32
58
|
|
|
33
59
|
Checks for valid user session on protected paths.
|
|
34
60
|
Compatible with OAuth flows from auth router.
|
|
61
|
+
Supports anonymous access with rate limiting.
|
|
62
|
+
MCP endpoints are always protected unless explicitly disabled.
|
|
35
63
|
"""
|
|
36
64
|
|
|
37
65
|
def __init__(
|
|
@@ -39,6 +67,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
39
67
|
app,
|
|
40
68
|
protected_paths: list[str] | None = None,
|
|
41
69
|
excluded_paths: list[str] | None = None,
|
|
70
|
+
allow_anonymous: bool = True,
|
|
71
|
+
mcp_requires_auth: bool = True,
|
|
72
|
+
mcp_path: str = "/api/v1/mcp",
|
|
42
73
|
):
|
|
43
74
|
"""
|
|
44
75
|
Initialize auth middleware.
|
|
@@ -47,10 +78,85 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
47
78
|
app: ASGI application
|
|
48
79
|
protected_paths: Paths that require authentication
|
|
49
80
|
excluded_paths: Paths to exclude from auth check
|
|
81
|
+
allow_anonymous: Allow unauthenticated requests (rate-limited)
|
|
82
|
+
mcp_requires_auth: Always require auth for MCP (protected service)
|
|
83
|
+
mcp_path: Path prefix for MCP endpoints
|
|
50
84
|
"""
|
|
51
85
|
super().__init__(app)
|
|
52
86
|
self.protected_paths = protected_paths or ["/api/v1"]
|
|
53
87
|
self.excluded_paths = excluded_paths or ["/api/auth", "/health", "/docs", "/openapi.json"]
|
|
88
|
+
self.allow_anonymous = allow_anonymous
|
|
89
|
+
self.mcp_requires_auth = mcp_requires_auth
|
|
90
|
+
self.mcp_path = mcp_path
|
|
91
|
+
|
|
92
|
+
def _check_api_key(self, request: Request) -> dict | None:
|
|
93
|
+
"""
|
|
94
|
+
Check for valid X-API-Key header.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
API key user dict if valid, None otherwise
|
|
98
|
+
"""
|
|
99
|
+
# Only check if API key auth is enabled
|
|
100
|
+
if not settings.api.api_key_enabled:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Check for X-API-Key header
|
|
104
|
+
api_key = request.headers.get("x-api-key")
|
|
105
|
+
if not api_key:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# Validate against configured API key
|
|
109
|
+
if settings.api.api_key and api_key == settings.api.api_key:
|
|
110
|
+
logger.debug("X-API-Key authenticated")
|
|
111
|
+
return {
|
|
112
|
+
"id": "api-key-user",
|
|
113
|
+
"email": "api@rem.local",
|
|
114
|
+
"name": "API Key User",
|
|
115
|
+
"provider": "api-key",
|
|
116
|
+
"tenant_id": "default",
|
|
117
|
+
"tier": "pro", # API key users get full access
|
|
118
|
+
"roles": ["user"],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Invalid API key
|
|
122
|
+
logger.warning("Invalid X-API-Key provided")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def _check_dev_token(self, request: Request) -> dict | None:
|
|
126
|
+
"""
|
|
127
|
+
Check for valid dev token in Authorization header (non-production only).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Test user dict if valid dev token, None otherwise
|
|
131
|
+
"""
|
|
132
|
+
if settings.environment == "production":
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
auth_header = request.headers.get("authorization", "")
|
|
136
|
+
if not auth_header.startswith("Bearer "):
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
token = auth_header[7:] # Strip "Bearer "
|
|
140
|
+
|
|
141
|
+
# Only check dev tokens (start with "dev_")
|
|
142
|
+
if not token.startswith("dev_"):
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# Verify dev token
|
|
146
|
+
from ..api.routers.dev import verify_dev_token
|
|
147
|
+
if verify_dev_token(token):
|
|
148
|
+
logger.debug("Dev token authenticated as test-user")
|
|
149
|
+
return {
|
|
150
|
+
"id": "test-user",
|
|
151
|
+
"email": "test@rem.local",
|
|
152
|
+
"name": "Test User",
|
|
153
|
+
"provider": "dev",
|
|
154
|
+
"tenant_id": "default",
|
|
155
|
+
"tier": "pro", # Give test user pro tier for full access
|
|
156
|
+
"roles": ["admin"],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return None
|
|
54
160
|
|
|
55
161
|
async def dispatch(self, request: Request, call_next):
|
|
56
162
|
"""
|
|
@@ -61,7 +167,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
61
167
|
call_next: Next middleware in chain
|
|
62
168
|
|
|
63
169
|
Returns:
|
|
64
|
-
Response (401/redirect if unauthorized, normal response if authorized)
|
|
170
|
+
Response (401/redirect if unauthorized, normal response if authorized/anonymous)
|
|
65
171
|
"""
|
|
66
172
|
path = request.url.path
|
|
67
173
|
|
|
@@ -69,32 +175,90 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
69
175
|
is_protected = any(path.startswith(p) for p in self.protected_paths)
|
|
70
176
|
is_excluded = any(path.startswith(p) for p in self.excluded_paths)
|
|
71
177
|
|
|
178
|
+
# Check if this is an MCP path (paid service, always requires auth)
|
|
179
|
+
is_mcp_path = path.startswith(self.mcp_path)
|
|
180
|
+
|
|
72
181
|
# Skip auth check for excluded paths
|
|
73
182
|
if not is_protected or is_excluded:
|
|
74
183
|
return await call_next(request)
|
|
75
184
|
|
|
76
|
-
# Check for
|
|
77
|
-
|
|
78
|
-
if
|
|
79
|
-
|
|
185
|
+
# Check for X-API-Key header first (if enabled)
|
|
186
|
+
api_key_user = self._check_api_key(request)
|
|
187
|
+
if api_key_user:
|
|
188
|
+
request.state.user = api_key_user
|
|
189
|
+
request.state.is_anonymous = False
|
|
190
|
+
return await call_next(request)
|
|
80
191
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if
|
|
192
|
+
# If API key auth is enabled but no valid key provided, reject immediately
|
|
193
|
+
if settings.api.api_key_enabled:
|
|
194
|
+
# Check if X-API-Key header was provided but invalid
|
|
195
|
+
if request.headers.get("x-api-key"):
|
|
196
|
+
logger.warning(f"Invalid X-API-Key for: {path}")
|
|
85
197
|
return JSONResponse(
|
|
86
198
|
status_code=401,
|
|
87
|
-
content={"detail": "
|
|
88
|
-
headers={
|
|
89
|
-
"WWW-Authenticate": 'Bearer realm="REM API"',
|
|
90
|
-
},
|
|
199
|
+
content={"detail": "Invalid API key"},
|
|
200
|
+
headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
|
|
91
201
|
)
|
|
202
|
+
# No API key provided when required
|
|
203
|
+
logger.debug(f"Missing X-API-Key for: {path}")
|
|
204
|
+
return JSONResponse(
|
|
205
|
+
status_code=401,
|
|
206
|
+
content={"detail": "API key required. Include X-API-Key header."},
|
|
207
|
+
headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Check for dev token (non-production only)
|
|
211
|
+
dev_user = self._check_dev_token(request)
|
|
212
|
+
if dev_user:
|
|
213
|
+
request.state.user = dev_user
|
|
214
|
+
request.state.is_anonymous = False
|
|
215
|
+
return await call_next(request)
|
|
216
|
+
|
|
217
|
+
# Check for valid session
|
|
218
|
+
user = request.session.get("user")
|
|
219
|
+
|
|
220
|
+
if user:
|
|
221
|
+
# Authenticated user - add to request state
|
|
222
|
+
request.state.user = user
|
|
223
|
+
request.state.is_anonymous = False
|
|
224
|
+
return await call_next(request)
|
|
225
|
+
|
|
226
|
+
# No user session - check if MCP path requires auth
|
|
227
|
+
if is_mcp_path and self.mcp_requires_auth:
|
|
228
|
+
# MCP is a protected service - always require authentication
|
|
229
|
+
logger.warning(f"Unauthorized MCP access attempt: {path}")
|
|
230
|
+
return JSONResponse(
|
|
231
|
+
status_code=401,
|
|
232
|
+
content={
|
|
233
|
+
"detail": "Authentication required for MCP. Please login to use this service.",
|
|
234
|
+
"code": "MCP_AUTH_REQUIRED",
|
|
235
|
+
},
|
|
236
|
+
headers={
|
|
237
|
+
"WWW-Authenticate": 'Bearer realm="REM MCP"',
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# No user session - handle anonymous access for non-MCP paths
|
|
242
|
+
if self.allow_anonymous:
|
|
243
|
+
# Allow anonymous access - rate limiting handled downstream
|
|
244
|
+
request.state.user = None
|
|
245
|
+
request.state.is_anonymous = True
|
|
246
|
+
logger.debug(f"Anonymous access: {path}")
|
|
247
|
+
return await call_next(request)
|
|
92
248
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return RedirectResponse(url="/api/auth/google/login", status_code=302)
|
|
249
|
+
# Anonymous not allowed - require authentication
|
|
250
|
+
logger.warning(f"Unauthorized access attempt: {path}")
|
|
96
251
|
|
|
97
|
-
#
|
|
98
|
-
request.
|
|
252
|
+
# Return 401 for API requests (JSON)
|
|
253
|
+
accept = request.headers.get("accept", "")
|
|
254
|
+
if "application/json" in accept or path.startswith("/api/"):
|
|
255
|
+
return JSONResponse(
|
|
256
|
+
status_code=401,
|
|
257
|
+
content={"detail": "Authentication required"},
|
|
258
|
+
headers={
|
|
259
|
+
"WWW-Authenticate": 'Bearer realm="REM API"',
|
|
260
|
+
},
|
|
261
|
+
)
|
|
99
262
|
|
|
100
|
-
|
|
263
|
+
# Redirect to login for browser requests
|
|
264
|
+
return RedirectResponse(url="/api/auth/google/login", status_code=302)
|
rem/auth/providers/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Authentication provider implementations."""
|
|
2
2
|
|
|
3
3
|
from .base import OAuthProvider, OAuthTokens, OAuthUserInfo
|
|
4
|
+
from .email import EmailAuthProvider, EmailAuthResult
|
|
4
5
|
from .google import GoogleOAuthProvider
|
|
5
6
|
from .microsoft import MicrosoftOAuthProvider
|
|
6
7
|
|
|
@@ -8,6 +9,8 @@ __all__ = [
|
|
|
8
9
|
"OAuthProvider",
|
|
9
10
|
"OAuthTokens",
|
|
10
11
|
"OAuthUserInfo",
|
|
12
|
+
"EmailAuthProvider",
|
|
13
|
+
"EmailAuthResult",
|
|
11
14
|
"GoogleOAuthProvider",
|
|
12
15
|
"MicrosoftOAuthProvider",
|
|
13
16
|
]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email Authentication Provider.
|
|
3
|
+
|
|
4
|
+
Passwordless authentication using email verification codes.
|
|
5
|
+
Unlike OAuth providers, this handles the full flow internally.
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
1. User requests login with email address
|
|
9
|
+
2. System generates code, upserts user, sends email
|
|
10
|
+
3. User enters code
|
|
11
|
+
4. System verifies code and creates session
|
|
12
|
+
|
|
13
|
+
Design:
|
|
14
|
+
- Uses EmailService for sending codes
|
|
15
|
+
- Creates users with deterministic UUID from email hash
|
|
16
|
+
- Stores challenge in user metadata
|
|
17
|
+
- No external OAuth dependencies
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
from loguru import logger
|
|
23
|
+
|
|
24
|
+
from ...services.email import EmailService
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ...services.postgres import PostgresService
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EmailAuthResult(BaseModel):
|
|
31
|
+
"""Result of email authentication operations."""
|
|
32
|
+
|
|
33
|
+
success: bool = Field(description="Whether operation succeeded")
|
|
34
|
+
email: str = Field(description="Email address")
|
|
35
|
+
user_id: str | None = Field(default=None, description="User ID if authenticated")
|
|
36
|
+
error: str | None = Field(default=None, description="Error message if failed")
|
|
37
|
+
message: str | None = Field(default=None, description="User-friendly message")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EmailAuthProvider:
|
|
41
|
+
"""
|
|
42
|
+
Email-based passwordless authentication provider.
|
|
43
|
+
|
|
44
|
+
Handles the complete email login flow:
|
|
45
|
+
1. send_code() - Generate and send verification code
|
|
46
|
+
2. verify_code() - Verify code and return user info
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
email_service: EmailService | None = None,
|
|
52
|
+
template_kwargs: dict | None = None,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize EmailAuthProvider.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
email_service: EmailService instance (creates new one if not provided)
|
|
59
|
+
template_kwargs: Customization for email templates (colors, branding, etc.)
|
|
60
|
+
"""
|
|
61
|
+
self._email_service = email_service or EmailService()
|
|
62
|
+
self._template_kwargs = template_kwargs or {}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_configured(self) -> bool:
|
|
66
|
+
"""Check if email auth is properly configured."""
|
|
67
|
+
return self._email_service.is_configured
|
|
68
|
+
|
|
69
|
+
async def send_code(
|
|
70
|
+
self,
|
|
71
|
+
email: str,
|
|
72
|
+
db: "PostgresService",
|
|
73
|
+
tenant_id: str = "default",
|
|
74
|
+
) -> EmailAuthResult:
|
|
75
|
+
"""
|
|
76
|
+
Send a verification code to an email address.
|
|
77
|
+
|
|
78
|
+
Creates user if not exists (using deterministic UUID from email).
|
|
79
|
+
Stores code in user metadata.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
email: Email address to send code to
|
|
83
|
+
db: PostgresService instance
|
|
84
|
+
tenant_id: Tenant identifier
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
EmailAuthResult with success status
|
|
88
|
+
"""
|
|
89
|
+
if not self.is_configured:
|
|
90
|
+
return EmailAuthResult(
|
|
91
|
+
success=False,
|
|
92
|
+
email=email,
|
|
93
|
+
error="Email service not configured",
|
|
94
|
+
message="Email login is not available. Please try another method.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = await self._email_service.send_login_code(
|
|
99
|
+
email=email,
|
|
100
|
+
db=db,
|
|
101
|
+
tenant_id=tenant_id,
|
|
102
|
+
template_kwargs=self._template_kwargs,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if result["success"]:
|
|
106
|
+
return EmailAuthResult(
|
|
107
|
+
success=True,
|
|
108
|
+
email=email,
|
|
109
|
+
user_id=result["user_id"],
|
|
110
|
+
message=f"Verification code sent to {email}. Check your inbox.",
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
return EmailAuthResult(
|
|
114
|
+
success=False,
|
|
115
|
+
email=email,
|
|
116
|
+
error=result.get("error", "Failed to send code"),
|
|
117
|
+
message="Failed to send verification code. Please try again.",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Error sending login code: {e}")
|
|
122
|
+
return EmailAuthResult(
|
|
123
|
+
success=False,
|
|
124
|
+
email=email,
|
|
125
|
+
error=str(e),
|
|
126
|
+
message="An error occurred. Please try again.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def verify_code(
|
|
130
|
+
self,
|
|
131
|
+
email: str,
|
|
132
|
+
code: str,
|
|
133
|
+
db: "PostgresService",
|
|
134
|
+
tenant_id: str = "default",
|
|
135
|
+
) -> EmailAuthResult:
|
|
136
|
+
"""
|
|
137
|
+
Verify a login code and authenticate user.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
email: Email address
|
|
141
|
+
code: 6-digit verification code
|
|
142
|
+
db: PostgresService instance
|
|
143
|
+
tenant_id: Tenant identifier
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
EmailAuthResult with user_id if successful
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
result = await self._email_service.verify_login_code(
|
|
150
|
+
email=email,
|
|
151
|
+
code=code,
|
|
152
|
+
db=db,
|
|
153
|
+
tenant_id=tenant_id,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if result["valid"]:
|
|
157
|
+
return EmailAuthResult(
|
|
158
|
+
success=True,
|
|
159
|
+
email=email,
|
|
160
|
+
user_id=result["user_id"],
|
|
161
|
+
message="Successfully authenticated!",
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
error = result.get("error", "Invalid code")
|
|
165
|
+
# User-friendly error messages
|
|
166
|
+
if error == "Login code expired":
|
|
167
|
+
message = "Your code has expired. Please request a new one."
|
|
168
|
+
elif error == "Invalid login code":
|
|
169
|
+
message = "Invalid code. Please check and try again."
|
|
170
|
+
elif error == "No login code requested":
|
|
171
|
+
message = "No code was requested for this email. Please request a new code."
|
|
172
|
+
elif error == "User not found":
|
|
173
|
+
message = "Email not found. Please request a login code first."
|
|
174
|
+
else:
|
|
175
|
+
message = "Verification failed. Please try again."
|
|
176
|
+
|
|
177
|
+
return EmailAuthResult(
|
|
178
|
+
success=False,
|
|
179
|
+
email=email,
|
|
180
|
+
error=error,
|
|
181
|
+
message=message,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error(f"Error verifying login code: {e}")
|
|
186
|
+
return EmailAuthResult(
|
|
187
|
+
success=False,
|
|
188
|
+
email=email,
|
|
189
|
+
error=str(e),
|
|
190
|
+
message="An error occurred. Please try again.",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def get_user_dict(self, email: str, user_id: str) -> dict:
|
|
194
|
+
"""
|
|
195
|
+
Create a user dict for session storage.
|
|
196
|
+
|
|
197
|
+
Compatible with OAuth user format for consistent session handling.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
email: User's email
|
|
201
|
+
user_id: User's UUID
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
User dict for session
|
|
205
|
+
"""
|
|
206
|
+
return {
|
|
207
|
+
"id": user_id,
|
|
208
|
+
"email": email,
|
|
209
|
+
"email_verified": True, # Email is verified through code
|
|
210
|
+
"name": email.split("@")[0], # Use email prefix as name
|
|
211
|
+
"provider": "email",
|
|
212
|
+
"tenant_id": "default",
|
|
213
|
+
"tier": "free", # Email users start at free tier
|
|
214
|
+
"roles": ["user"],
|
|
215
|
+
}
|