remdb 0.3.114__py3-none-any.whl → 0.3.172__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +2 -0
- rem/agentic/context.py +103 -5
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +161 -18
- rem/agentic/otel/setup.py +1 -0
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +172 -30
- rem/agentic/schema.py +8 -4
- rem/api/deps.py +3 -5
- rem/api/main.py +26 -4
- rem/api/mcp_router/resources.py +15 -10
- rem/api/mcp_router/server.py +11 -3
- rem/api/mcp_router/tools.py +418 -4
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/admin.py +218 -1
- rem/api/routers/auth.py +349 -6
- rem/api/routers/chat/completions.py +255 -7
- rem/api/routers/chat/models.py +81 -7
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +17 -1
- rem/api/routers/chat/streaming.py +126 -19
- rem/api/routers/feedback.py +134 -14
- rem/api/routers/messages.py +24 -15
- rem/api/routers/query.py +6 -3
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +115 -10
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +42 -0
- rem/cli/commands/cluster.py +617 -168
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +66 -22
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/schema.py +6 -5
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/config.py +8 -1
- rem/models/core/experiment.py +58 -14
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -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/services/__init__.py +3 -1
- rem/services/content/service.py +4 -3
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +513 -0
- rem/services/email/templates.py +360 -0
- rem/services/phoenix/client.py +59 -18
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +127 -6
- rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
- rem/services/postgres/repository.py +5 -4
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/session/compression.py +120 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +442 -23
- rem/sql/migrations/001_install.sql +156 -0
- rem/sql/migrations/002_install_models.sql +1951 -88
- 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/files.py +157 -1
- rem/utils/schema_loader.py +139 -10
- rem/utils/sql_paths.py +146 -0
- 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.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
rem/auth/jwt.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JWT Token Service for REM Authentication.
|
|
3
|
+
|
|
4
|
+
Provides JWT token generation and validation for stateless authentication.
|
|
5
|
+
Uses HS256 algorithm with the session secret for signing.
|
|
6
|
+
|
|
7
|
+
Token Types:
|
|
8
|
+
- Access Token: Short-lived (default 1 hour), used for API authentication
|
|
9
|
+
- Refresh Token: Long-lived (default 7 days), used to obtain new access tokens
|
|
10
|
+
|
|
11
|
+
Token Claims:
|
|
12
|
+
- sub: User ID (UUID string)
|
|
13
|
+
- email: User email
|
|
14
|
+
- name: User display name
|
|
15
|
+
- role: User role (user, admin)
|
|
16
|
+
- tier: User subscription tier
|
|
17
|
+
- roles: List of roles for authorization
|
|
18
|
+
- provider: Auth provider (email, google, microsoft)
|
|
19
|
+
- tenant_id: Tenant identifier for multi-tenancy
|
|
20
|
+
- exp: Expiration timestamp
|
|
21
|
+
- iat: Issued at timestamp
|
|
22
|
+
- type: Token type (access, refresh)
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
from rem.auth.jwt import JWTService
|
|
26
|
+
|
|
27
|
+
jwt_service = JWTService()
|
|
28
|
+
|
|
29
|
+
# Generate tokens after successful authentication
|
|
30
|
+
tokens = jwt_service.create_tokens(user_dict)
|
|
31
|
+
# Returns: {"access_token": "...", "refresh_token": "...", "token_type": "bearer", "expires_in": 3600}
|
|
32
|
+
|
|
33
|
+
# Validate token from Authorization header
|
|
34
|
+
user = jwt_service.verify_token(token)
|
|
35
|
+
# Returns user dict or None if invalid
|
|
36
|
+
|
|
37
|
+
# Refresh access token
|
|
38
|
+
new_tokens = jwt_service.refresh_access_token(refresh_token)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import time
|
|
42
|
+
import hmac
|
|
43
|
+
import hashlib
|
|
44
|
+
import base64
|
|
45
|
+
import json
|
|
46
|
+
from datetime import datetime, timezone
|
|
47
|
+
from typing import Optional
|
|
48
|
+
|
|
49
|
+
from loguru import logger
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class JWTService:
|
|
53
|
+
"""
|
|
54
|
+
JWT token service for authentication.
|
|
55
|
+
|
|
56
|
+
Uses HMAC-SHA256 for signing - simple and secure for single-service deployment.
|
|
57
|
+
For multi-service deployments, consider switching to RS256 with public/private keys.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
secret: str | None = None,
|
|
63
|
+
access_token_expiry_seconds: int = 3600, # 1 hour
|
|
64
|
+
refresh_token_expiry_seconds: int = 604800, # 7 days
|
|
65
|
+
issuer: str = "rem",
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
Initialize JWT service.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
secret: Secret key for signing (uses settings.auth.session_secret if not provided)
|
|
72
|
+
access_token_expiry_seconds: Access token lifetime in seconds
|
|
73
|
+
refresh_token_expiry_seconds: Refresh token lifetime in seconds
|
|
74
|
+
issuer: Token issuer identifier
|
|
75
|
+
"""
|
|
76
|
+
if secret:
|
|
77
|
+
self._secret = secret
|
|
78
|
+
else:
|
|
79
|
+
from ..settings import settings
|
|
80
|
+
self._secret = settings.auth.session_secret
|
|
81
|
+
|
|
82
|
+
self._access_expiry = access_token_expiry_seconds
|
|
83
|
+
self._refresh_expiry = refresh_token_expiry_seconds
|
|
84
|
+
self._issuer = issuer
|
|
85
|
+
|
|
86
|
+
def _base64url_encode(self, data: bytes) -> str:
|
|
87
|
+
"""Base64url encode without padding."""
|
|
88
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
|
|
89
|
+
|
|
90
|
+
def _base64url_decode(self, data: str) -> bytes:
|
|
91
|
+
"""Base64url decode with padding restoration."""
|
|
92
|
+
padding = 4 - len(data) % 4
|
|
93
|
+
if padding != 4:
|
|
94
|
+
data += "=" * padding
|
|
95
|
+
return base64.urlsafe_b64decode(data)
|
|
96
|
+
|
|
97
|
+
def _sign(self, message: str) -> str:
|
|
98
|
+
"""Create HMAC-SHA256 signature."""
|
|
99
|
+
signature = hmac.new(
|
|
100
|
+
self._secret.encode("utf-8"),
|
|
101
|
+
message.encode("utf-8"),
|
|
102
|
+
hashlib.sha256
|
|
103
|
+
).digest()
|
|
104
|
+
return self._base64url_encode(signature)
|
|
105
|
+
|
|
106
|
+
def _create_token(self, payload: dict) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Create a JWT token.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
payload: Token claims
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Encoded JWT string
|
|
115
|
+
"""
|
|
116
|
+
header = {"alg": "HS256", "typ": "JWT"}
|
|
117
|
+
|
|
118
|
+
header_encoded = self._base64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
|
119
|
+
payload_encoded = self._base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
|
120
|
+
|
|
121
|
+
message = f"{header_encoded}.{payload_encoded}"
|
|
122
|
+
signature = self._sign(message)
|
|
123
|
+
|
|
124
|
+
return f"{message}.{signature}"
|
|
125
|
+
|
|
126
|
+
def _verify_signature(self, token: str) -> dict | None:
|
|
127
|
+
"""
|
|
128
|
+
Verify token signature and decode payload.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
token: JWT token string
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Decoded payload dict or None if invalid
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
parts = token.split(".")
|
|
138
|
+
if len(parts) != 3:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
header_encoded, payload_encoded, signature = parts
|
|
142
|
+
|
|
143
|
+
# Verify signature
|
|
144
|
+
message = f"{header_encoded}.{payload_encoded}"
|
|
145
|
+
expected_signature = self._sign(message)
|
|
146
|
+
|
|
147
|
+
if not hmac.compare_digest(signature, expected_signature):
|
|
148
|
+
logger.debug("JWT signature verification failed")
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# Decode payload
|
|
152
|
+
payload = json.loads(self._base64url_decode(payload_encoded))
|
|
153
|
+
return payload
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.debug(f"JWT decode error: {e}")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def create_tokens(
|
|
160
|
+
self,
|
|
161
|
+
user: dict,
|
|
162
|
+
access_expiry: int | None = None,
|
|
163
|
+
refresh_expiry: int | None = None,
|
|
164
|
+
) -> dict:
|
|
165
|
+
"""
|
|
166
|
+
Create access and refresh tokens for a user.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
user: User dict with id, email, name, role, tier, roles, provider, tenant_id
|
|
170
|
+
access_expiry: Override access token expiry (seconds)
|
|
171
|
+
refresh_expiry: Override refresh token expiry (seconds)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Dict with access_token, refresh_token, token_type, expires_in
|
|
175
|
+
"""
|
|
176
|
+
now = int(time.time())
|
|
177
|
+
access_exp = access_expiry or self._access_expiry
|
|
178
|
+
refresh_exp = refresh_expiry or self._refresh_expiry
|
|
179
|
+
|
|
180
|
+
# Common claims
|
|
181
|
+
base_claims = {
|
|
182
|
+
"sub": user.get("id", ""),
|
|
183
|
+
"email": user.get("email", ""),
|
|
184
|
+
"name": user.get("name", ""),
|
|
185
|
+
"role": user.get("role"),
|
|
186
|
+
"tier": user.get("tier", "free"),
|
|
187
|
+
"roles": user.get("roles", ["user"]),
|
|
188
|
+
"provider": user.get("provider", "email"),
|
|
189
|
+
"tenant_id": user.get("tenant_id", "default"),
|
|
190
|
+
"iss": self._issuer,
|
|
191
|
+
"iat": now,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Access token
|
|
195
|
+
access_payload = {
|
|
196
|
+
**base_claims,
|
|
197
|
+
"type": "access",
|
|
198
|
+
"exp": now + access_exp,
|
|
199
|
+
}
|
|
200
|
+
access_token = self._create_token(access_payload)
|
|
201
|
+
|
|
202
|
+
# Refresh token (minimal claims for security)
|
|
203
|
+
refresh_payload = {
|
|
204
|
+
"sub": user.get("id", ""),
|
|
205
|
+
"email": user.get("email", ""),
|
|
206
|
+
"type": "refresh",
|
|
207
|
+
"iss": self._issuer,
|
|
208
|
+
"iat": now,
|
|
209
|
+
"exp": now + refresh_exp,
|
|
210
|
+
}
|
|
211
|
+
refresh_token = self._create_token(refresh_payload)
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"access_token": access_token,
|
|
215
|
+
"refresh_token": refresh_token,
|
|
216
|
+
"token_type": "bearer",
|
|
217
|
+
"expires_in": access_exp,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
def verify_token(self, token: str, token_type: str = "access") -> dict | None:
|
|
221
|
+
"""
|
|
222
|
+
Verify a token and return user claims.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
token: JWT token string
|
|
226
|
+
token_type: Expected token type ("access" or "refresh")
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
User dict with claims or None if invalid/expired
|
|
230
|
+
"""
|
|
231
|
+
payload = self._verify_signature(token)
|
|
232
|
+
if not payload:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# Check token type
|
|
236
|
+
if payload.get("type") != token_type:
|
|
237
|
+
logger.debug(f"Token type mismatch: expected {token_type}, got {payload.get('type')}")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
# Check expiration
|
|
241
|
+
exp = payload.get("exp", 0)
|
|
242
|
+
if exp < time.time():
|
|
243
|
+
logger.debug("Token expired")
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
# Check issuer
|
|
247
|
+
if payload.get("iss") != self._issuer:
|
|
248
|
+
logger.debug(f"Token issuer mismatch: expected {self._issuer}, got {payload.get('iss')}")
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
# Return user dict (compatible with session user format)
|
|
252
|
+
return {
|
|
253
|
+
"id": payload.get("sub"),
|
|
254
|
+
"email": payload.get("email"),
|
|
255
|
+
"name": payload.get("name"),
|
|
256
|
+
"role": payload.get("role"),
|
|
257
|
+
"tier": payload.get("tier", "free"),
|
|
258
|
+
"roles": payload.get("roles", ["user"]),
|
|
259
|
+
"provider": payload.get("provider", "email"),
|
|
260
|
+
"tenant_id": payload.get("tenant_id", "default"),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def refresh_access_token(self, refresh_token: str) -> dict | None:
|
|
264
|
+
"""
|
|
265
|
+
Create new access token using refresh token.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
refresh_token: Valid refresh token
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
New token dict or None if refresh token is invalid
|
|
272
|
+
"""
|
|
273
|
+
# Verify refresh token
|
|
274
|
+
payload = self._verify_signature(refresh_token)
|
|
275
|
+
if not payload:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
if payload.get("type") != "refresh":
|
|
279
|
+
logger.debug("Not a refresh token")
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
# Check expiration
|
|
283
|
+
exp = payload.get("exp", 0)
|
|
284
|
+
if exp < time.time():
|
|
285
|
+
logger.debug("Refresh token expired")
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
# Create new access token with minimal info from refresh token
|
|
289
|
+
# In production, you'd look up the full user from database
|
|
290
|
+
user = {
|
|
291
|
+
"id": payload.get("sub"),
|
|
292
|
+
"email": payload.get("email"),
|
|
293
|
+
"name": payload.get("email", "").split("@")[0],
|
|
294
|
+
"provider": "email",
|
|
295
|
+
"tenant_id": "default",
|
|
296
|
+
"tier": "free",
|
|
297
|
+
"roles": ["user"],
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# Only return new access token, keep same refresh token
|
|
301
|
+
now = int(time.time())
|
|
302
|
+
access_payload = {
|
|
303
|
+
"sub": user["id"],
|
|
304
|
+
"email": user["email"],
|
|
305
|
+
"name": user["name"],
|
|
306
|
+
"role": user.get("role"),
|
|
307
|
+
"tier": user["tier"],
|
|
308
|
+
"roles": user["roles"],
|
|
309
|
+
"provider": user["provider"],
|
|
310
|
+
"tenant_id": user["tenant_id"],
|
|
311
|
+
"type": "access",
|
|
312
|
+
"iss": self._issuer,
|
|
313
|
+
"iat": now,
|
|
314
|
+
"exp": now + self._access_expiry,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
"access_token": self._create_token(access_payload),
|
|
319
|
+
"token_type": "bearer",
|
|
320
|
+
"expires_in": self._access_expiry,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
def decode_without_verification(self, token: str) -> dict | None:
|
|
324
|
+
"""
|
|
325
|
+
Decode token without verification (for debugging only).
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
token: JWT token string
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Decoded payload or None
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
parts = token.split(".")
|
|
335
|
+
if len(parts) != 3:
|
|
336
|
+
return None
|
|
337
|
+
payload = json.loads(self._base64url_decode(parts[1]))
|
|
338
|
+
return payload
|
|
339
|
+
except Exception:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# Singleton instance for convenience
|
|
344
|
+
_jwt_service: Optional[JWTService] = None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def get_jwt_service() -> JWTService:
|
|
348
|
+
"""Get or create the JWT service singleton."""
|
|
349
|
+
global _jwt_service
|
|
350
|
+
if _jwt_service is None:
|
|
351
|
+
_jwt_service = JWTService()
|
|
352
|
+
return _jwt_service
|
rem/auth/middleware.py
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Authentication Middleware for FastAPI.
|
|
3
3
|
|
|
4
|
-
Protects API endpoints by requiring valid
|
|
5
|
-
Supports
|
|
4
|
+
Protects API endpoints by requiring valid authentication.
|
|
5
|
+
Supports multiple auth methods: JWT, API Key, Session, Dev Token.
|
|
6
|
+
Anonymous access with rate limiting when allow_anonymous=True.
|
|
6
7
|
MCP endpoints are always protected unless explicitly disabled.
|
|
7
8
|
|
|
8
9
|
Design Pattern:
|
|
9
|
-
-
|
|
10
|
-
-
|
|
10
|
+
- API Key (X-API-Key): Access control guardrail, NOT user identity
|
|
11
|
+
- JWT (Authorization: Bearer): Primary method for user identity
|
|
12
|
+
- Dev token: Non-production testing (starts with "dev_")
|
|
13
|
+
- Session: Backward compatibility for browser-based auth
|
|
11
14
|
- MCP paths always require authentication (protected service)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
Authentication Flow:
|
|
17
|
+
1. If API key enabled: Validate X-API-Key header (access gate)
|
|
18
|
+
2. Check JWT token for user identity (primary)
|
|
19
|
+
3. Check dev token for testing (non-production only)
|
|
20
|
+
4. Check session for user (backward compatibility)
|
|
21
|
+
5. If allow_anonymous=True: Allow as anonymous (rate-limited)
|
|
22
|
+
6. If allow_anonymous=False: Return 401 / redirect to login
|
|
23
|
+
|
|
24
|
+
IMPORTANT: API key validates ACCESS, JWT identifies USER.
|
|
25
|
+
Both can be required: API key for access + JWT for user identity.
|
|
15
26
|
|
|
16
27
|
Access Modes (configured in settings.auth):
|
|
17
28
|
- enabled=true, allow_anonymous=true: Auth available, anonymous gets rate-limited access
|
|
@@ -20,6 +31,11 @@ Access Modes (configured in settings.auth):
|
|
|
20
31
|
- mcp_requires_auth=true (default): MCP always requires login regardless of allow_anonymous
|
|
21
32
|
- mcp_requires_auth=false: MCP follows normal allow_anonymous rules (dev only)
|
|
22
33
|
|
|
34
|
+
API Key Authentication (configured in settings.api):
|
|
35
|
+
- api_key_enabled=true: Require X-API-Key header for access
|
|
36
|
+
- api_key: The secret key to validate against
|
|
37
|
+
- API key is an ACCESS GATE, not user identity - JWT still needed for user
|
|
38
|
+
|
|
23
39
|
Dev Token Support (non-production only):
|
|
24
40
|
- GET /api/auth/dev/token returns a Bearer token for test-user
|
|
25
41
|
- Include as: Authorization: Bearer dev_<signature>
|
|
@@ -82,6 +98,67 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
82
98
|
self.mcp_requires_auth = mcp_requires_auth
|
|
83
99
|
self.mcp_path = mcp_path
|
|
84
100
|
|
|
101
|
+
def _check_api_key(self, request: Request) -> dict | None:
|
|
102
|
+
"""
|
|
103
|
+
Check for valid X-API-Key header.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
API key user dict if valid, None otherwise
|
|
107
|
+
"""
|
|
108
|
+
# Only check if API key auth is enabled
|
|
109
|
+
if not settings.api.api_key_enabled:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
# Check for X-API-Key header
|
|
113
|
+
api_key = request.headers.get("x-api-key")
|
|
114
|
+
if not api_key:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
# Validate against configured API key
|
|
118
|
+
if settings.api.api_key and api_key == settings.api.api_key:
|
|
119
|
+
logger.debug("X-API-Key authenticated")
|
|
120
|
+
return {
|
|
121
|
+
"id": "api-key-user",
|
|
122
|
+
"email": "api@rem.local",
|
|
123
|
+
"name": "API Key User",
|
|
124
|
+
"provider": "api-key",
|
|
125
|
+
"tenant_id": "default",
|
|
126
|
+
"tier": "pro", # API key users get full access
|
|
127
|
+
"roles": ["user"],
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Invalid API key
|
|
131
|
+
logger.warning("Invalid X-API-Key provided")
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def _check_jwt_token(self, request: Request) -> dict | None:
|
|
135
|
+
"""
|
|
136
|
+
Check for valid JWT in Authorization header.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
User dict if valid JWT, None otherwise
|
|
140
|
+
"""
|
|
141
|
+
auth_header = request.headers.get("authorization", "")
|
|
142
|
+
if not auth_header.startswith("Bearer "):
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
token = auth_header[7:] # Strip "Bearer "
|
|
146
|
+
|
|
147
|
+
# Skip dev tokens (handled separately)
|
|
148
|
+
if token.startswith("dev_"):
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# Verify JWT token
|
|
152
|
+
from .jwt import get_jwt_service
|
|
153
|
+
jwt_service = get_jwt_service()
|
|
154
|
+
user = jwt_service.verify_token(token)
|
|
155
|
+
|
|
156
|
+
if user:
|
|
157
|
+
logger.debug(f"JWT authenticated: {user.get('email')}")
|
|
158
|
+
return user
|
|
159
|
+
|
|
160
|
+
return None
|
|
161
|
+
|
|
85
162
|
def _check_dev_token(self, request: Request) -> dict | None:
|
|
86
163
|
"""
|
|
87
164
|
Check for valid dev token in Authorization header (non-production only).
|
|
@@ -105,7 +182,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
105
182
|
# Verify dev token
|
|
106
183
|
from ..api.routers.dev import verify_dev_token
|
|
107
184
|
if verify_dev_token(token):
|
|
108
|
-
logger.debug(
|
|
185
|
+
logger.debug("Dev token authenticated as test-user")
|
|
109
186
|
return {
|
|
110
187
|
"id": "test-user",
|
|
111
188
|
"email": "test@rem.local",
|
|
@@ -142,6 +219,34 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
142
219
|
if not is_protected or is_excluded:
|
|
143
220
|
return await call_next(request)
|
|
144
221
|
|
|
222
|
+
# API key validation (access control, not user identity)
|
|
223
|
+
# API key is a guardrail for access - JWT identifies the actual user
|
|
224
|
+
if settings.api.api_key_enabled:
|
|
225
|
+
api_key = request.headers.get("x-api-key")
|
|
226
|
+
if not api_key:
|
|
227
|
+
logger.debug(f"Missing X-API-Key for: {path}")
|
|
228
|
+
return JSONResponse(
|
|
229
|
+
status_code=401,
|
|
230
|
+
content={"detail": "API key required. Include X-API-Key header."},
|
|
231
|
+
headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
|
|
232
|
+
)
|
|
233
|
+
if api_key != settings.api.api_key:
|
|
234
|
+
logger.warning(f"Invalid X-API-Key for: {path}")
|
|
235
|
+
return JSONResponse(
|
|
236
|
+
status_code=401,
|
|
237
|
+
content={"detail": "Invalid API key"},
|
|
238
|
+
headers={"WWW-Authenticate": 'ApiKey realm="REM API"'},
|
|
239
|
+
)
|
|
240
|
+
logger.debug("X-API-Key validated for access")
|
|
241
|
+
# API key valid - continue to check JWT for user identity
|
|
242
|
+
|
|
243
|
+
# Check for JWT token in Authorization header (primary user identity)
|
|
244
|
+
jwt_user = self._check_jwt_token(request)
|
|
245
|
+
if jwt_user:
|
|
246
|
+
request.state.user = jwt_user
|
|
247
|
+
request.state.is_anonymous = False
|
|
248
|
+
return await call_next(request)
|
|
249
|
+
|
|
145
250
|
# Check for dev token (non-production only)
|
|
146
251
|
dev_user = self._check_dev_token(request)
|
|
147
252
|
if dev_user:
|
|
@@ -149,7 +254,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
149
254
|
request.state.is_anonymous = False
|
|
150
255
|
return await call_next(request)
|
|
151
256
|
|
|
152
|
-
# Check for valid session
|
|
257
|
+
# Check for valid session (backward compatibility)
|
|
153
258
|
user = request.session.get("user")
|
|
154
259
|
|
|
155
260
|
if user:
|
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
|
]
|