remdb 0.3.242__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.

Files changed (235) hide show
  1. rem/__init__.py +129 -0
  2. rem/agentic/README.md +760 -0
  3. rem/agentic/__init__.py +54 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +38 -0
  6. rem/agentic/agents/agent_manager.py +311 -0
  7. rem/agentic/agents/sse_simulator.py +502 -0
  8. rem/agentic/context.py +425 -0
  9. rem/agentic/context_builder.py +360 -0
  10. rem/agentic/llm_provider_models.py +301 -0
  11. rem/agentic/mcp/__init__.py +0 -0
  12. rem/agentic/mcp/tool_wrapper.py +273 -0
  13. rem/agentic/otel/__init__.py +5 -0
  14. rem/agentic/otel/setup.py +240 -0
  15. rem/agentic/providers/phoenix.py +926 -0
  16. rem/agentic/providers/pydantic_ai.py +854 -0
  17. rem/agentic/query.py +117 -0
  18. rem/agentic/query_helper.py +89 -0
  19. rem/agentic/schema.py +737 -0
  20. rem/agentic/serialization.py +245 -0
  21. rem/agentic/tools/__init__.py +5 -0
  22. rem/agentic/tools/rem_tools.py +242 -0
  23. rem/api/README.md +657 -0
  24. rem/api/deps.py +253 -0
  25. rem/api/main.py +460 -0
  26. rem/api/mcp_router/prompts.py +182 -0
  27. rem/api/mcp_router/resources.py +820 -0
  28. rem/api/mcp_router/server.py +243 -0
  29. rem/api/mcp_router/tools.py +1605 -0
  30. rem/api/middleware/tracking.py +172 -0
  31. rem/api/routers/admin.py +520 -0
  32. rem/api/routers/auth.py +898 -0
  33. rem/api/routers/chat/__init__.py +5 -0
  34. rem/api/routers/chat/child_streaming.py +394 -0
  35. rem/api/routers/chat/completions.py +702 -0
  36. rem/api/routers/chat/json_utils.py +76 -0
  37. rem/api/routers/chat/models.py +202 -0
  38. rem/api/routers/chat/otel_utils.py +33 -0
  39. rem/api/routers/chat/sse_events.py +546 -0
  40. rem/api/routers/chat/streaming.py +950 -0
  41. rem/api/routers/chat/streaming_utils.py +327 -0
  42. rem/api/routers/common.py +18 -0
  43. rem/api/routers/dev.py +87 -0
  44. rem/api/routers/feedback.py +276 -0
  45. rem/api/routers/messages.py +620 -0
  46. rem/api/routers/models.py +86 -0
  47. rem/api/routers/query.py +362 -0
  48. rem/api/routers/shared_sessions.py +422 -0
  49. rem/auth/README.md +258 -0
  50. rem/auth/__init__.py +36 -0
  51. rem/auth/jwt.py +367 -0
  52. rem/auth/middleware.py +318 -0
  53. rem/auth/providers/__init__.py +16 -0
  54. rem/auth/providers/base.py +376 -0
  55. rem/auth/providers/email.py +215 -0
  56. rem/auth/providers/google.py +163 -0
  57. rem/auth/providers/microsoft.py +237 -0
  58. rem/cli/README.md +517 -0
  59. rem/cli/__init__.py +8 -0
  60. rem/cli/commands/README.md +299 -0
  61. rem/cli/commands/__init__.py +3 -0
  62. rem/cli/commands/ask.py +549 -0
  63. rem/cli/commands/cluster.py +1808 -0
  64. rem/cli/commands/configure.py +495 -0
  65. rem/cli/commands/db.py +828 -0
  66. rem/cli/commands/dreaming.py +324 -0
  67. rem/cli/commands/experiments.py +1698 -0
  68. rem/cli/commands/mcp.py +66 -0
  69. rem/cli/commands/process.py +388 -0
  70. rem/cli/commands/query.py +109 -0
  71. rem/cli/commands/scaffold.py +47 -0
  72. rem/cli/commands/schema.py +230 -0
  73. rem/cli/commands/serve.py +106 -0
  74. rem/cli/commands/session.py +453 -0
  75. rem/cli/dreaming.py +363 -0
  76. rem/cli/main.py +123 -0
  77. rem/config.py +244 -0
  78. rem/mcp_server.py +41 -0
  79. rem/models/core/__init__.py +49 -0
  80. rem/models/core/core_model.py +70 -0
  81. rem/models/core/engram.py +333 -0
  82. rem/models/core/experiment.py +672 -0
  83. rem/models/core/inline_edge.py +132 -0
  84. rem/models/core/rem_query.py +246 -0
  85. rem/models/entities/__init__.py +68 -0
  86. rem/models/entities/domain_resource.py +38 -0
  87. rem/models/entities/feedback.py +123 -0
  88. rem/models/entities/file.py +57 -0
  89. rem/models/entities/image_resource.py +88 -0
  90. rem/models/entities/message.py +64 -0
  91. rem/models/entities/moment.py +123 -0
  92. rem/models/entities/ontology.py +181 -0
  93. rem/models/entities/ontology_config.py +131 -0
  94. rem/models/entities/resource.py +95 -0
  95. rem/models/entities/schema.py +87 -0
  96. rem/models/entities/session.py +84 -0
  97. rem/models/entities/shared_session.py +180 -0
  98. rem/models/entities/subscriber.py +175 -0
  99. rem/models/entities/user.py +93 -0
  100. rem/py.typed +0 -0
  101. rem/registry.py +373 -0
  102. rem/schemas/README.md +507 -0
  103. rem/schemas/__init__.py +6 -0
  104. rem/schemas/agents/README.md +92 -0
  105. rem/schemas/agents/core/agent-builder.yaml +235 -0
  106. rem/schemas/agents/core/moment-builder.yaml +178 -0
  107. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  108. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  109. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  110. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  111. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  112. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  113. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  114. rem/schemas/agents/examples/hello-world.yaml +37 -0
  115. rem/schemas/agents/examples/query.yaml +54 -0
  116. rem/schemas/agents/examples/simple.yaml +21 -0
  117. rem/schemas/agents/examples/test.yaml +29 -0
  118. rem/schemas/agents/rem.yaml +132 -0
  119. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  120. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  121. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  122. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  123. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  124. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  125. rem/services/__init__.py +18 -0
  126. rem/services/audio/INTEGRATION.md +308 -0
  127. rem/services/audio/README.md +376 -0
  128. rem/services/audio/__init__.py +15 -0
  129. rem/services/audio/chunker.py +354 -0
  130. rem/services/audio/transcriber.py +259 -0
  131. rem/services/content/README.md +1269 -0
  132. rem/services/content/__init__.py +5 -0
  133. rem/services/content/providers.py +760 -0
  134. rem/services/content/service.py +762 -0
  135. rem/services/dreaming/README.md +230 -0
  136. rem/services/dreaming/__init__.py +53 -0
  137. rem/services/dreaming/affinity_service.py +322 -0
  138. rem/services/dreaming/moment_service.py +251 -0
  139. rem/services/dreaming/ontology_service.py +54 -0
  140. rem/services/dreaming/user_model_service.py +297 -0
  141. rem/services/dreaming/utils.py +39 -0
  142. rem/services/email/__init__.py +10 -0
  143. rem/services/email/service.py +522 -0
  144. rem/services/email/templates.py +360 -0
  145. rem/services/embeddings/__init__.py +11 -0
  146. rem/services/embeddings/api.py +127 -0
  147. rem/services/embeddings/worker.py +435 -0
  148. rem/services/fs/README.md +662 -0
  149. rem/services/fs/__init__.py +62 -0
  150. rem/services/fs/examples.py +206 -0
  151. rem/services/fs/examples_paths.py +204 -0
  152. rem/services/fs/git_provider.py +935 -0
  153. rem/services/fs/local_provider.py +760 -0
  154. rem/services/fs/parsing-hooks-examples.md +172 -0
  155. rem/services/fs/paths.py +276 -0
  156. rem/services/fs/provider.py +460 -0
  157. rem/services/fs/s3_provider.py +1042 -0
  158. rem/services/fs/service.py +186 -0
  159. rem/services/git/README.md +1075 -0
  160. rem/services/git/__init__.py +17 -0
  161. rem/services/git/service.py +469 -0
  162. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  163. rem/services/phoenix/README.md +453 -0
  164. rem/services/phoenix/__init__.py +46 -0
  165. rem/services/phoenix/client.py +960 -0
  166. rem/services/phoenix/config.py +88 -0
  167. rem/services/phoenix/prompt_labels.py +477 -0
  168. rem/services/postgres/README.md +757 -0
  169. rem/services/postgres/__init__.py +49 -0
  170. rem/services/postgres/diff_service.py +599 -0
  171. rem/services/postgres/migration_service.py +427 -0
  172. rem/services/postgres/programmable_diff_service.py +635 -0
  173. rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
  174. rem/services/postgres/register_type.py +353 -0
  175. rem/services/postgres/repository.py +481 -0
  176. rem/services/postgres/schema_generator.py +661 -0
  177. rem/services/postgres/service.py +802 -0
  178. rem/services/postgres/sql_builder.py +355 -0
  179. rem/services/rate_limit.py +113 -0
  180. rem/services/rem/README.md +318 -0
  181. rem/services/rem/__init__.py +23 -0
  182. rem/services/rem/exceptions.py +71 -0
  183. rem/services/rem/executor.py +293 -0
  184. rem/services/rem/parser.py +180 -0
  185. rem/services/rem/queries.py +196 -0
  186. rem/services/rem/query.py +371 -0
  187. rem/services/rem/service.py +608 -0
  188. rem/services/session/README.md +374 -0
  189. rem/services/session/__init__.py +13 -0
  190. rem/services/session/compression.py +488 -0
  191. rem/services/session/pydantic_messages.py +310 -0
  192. rem/services/session/reload.py +85 -0
  193. rem/services/user_service.py +130 -0
  194. rem/settings.py +1877 -0
  195. rem/sql/background_indexes.sql +52 -0
  196. rem/sql/migrations/001_install.sql +983 -0
  197. rem/sql/migrations/002_install_models.sql +3157 -0
  198. rem/sql/migrations/003_optional_extensions.sql +326 -0
  199. rem/sql/migrations/004_cache_system.sql +282 -0
  200. rem/sql/migrations/005_schema_update.sql +145 -0
  201. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  202. rem/utils/AGENTIC_CHUNKING.md +597 -0
  203. rem/utils/README.md +628 -0
  204. rem/utils/__init__.py +61 -0
  205. rem/utils/agentic_chunking.py +622 -0
  206. rem/utils/batch_ops.py +343 -0
  207. rem/utils/chunking.py +108 -0
  208. rem/utils/clip_embeddings.py +276 -0
  209. rem/utils/constants.py +97 -0
  210. rem/utils/date_utils.py +228 -0
  211. rem/utils/dict_utils.py +98 -0
  212. rem/utils/embeddings.py +436 -0
  213. rem/utils/examples/embeddings_example.py +305 -0
  214. rem/utils/examples/sql_types_example.py +202 -0
  215. rem/utils/files.py +323 -0
  216. rem/utils/markdown.py +16 -0
  217. rem/utils/mime_types.py +158 -0
  218. rem/utils/model_helpers.py +492 -0
  219. rem/utils/schema_loader.py +649 -0
  220. rem/utils/sql_paths.py +146 -0
  221. rem/utils/sql_types.py +350 -0
  222. rem/utils/user_id.py +81 -0
  223. rem/utils/vision.py +325 -0
  224. rem/workers/README.md +506 -0
  225. rem/workers/__init__.py +7 -0
  226. rem/workers/db_listener.py +579 -0
  227. rem/workers/db_maintainer.py +74 -0
  228. rem/workers/dreaming.py +502 -0
  229. rem/workers/engram_processor.py +312 -0
  230. rem/workers/sqs_file_processor.py +193 -0
  231. rem/workers/unlogged_maintainer.py +463 -0
  232. remdb-0.3.242.dist-info/METADATA +1632 -0
  233. remdb-0.3.242.dist-info/RECORD +235 -0
  234. remdb-0.3.242.dist-info/WHEEL +4 -0
  235. remdb-0.3.242.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,376 @@
1
+ """
2
+ Base OAuth Provider for OAuth 2.1 compliant authentication.
3
+
4
+ OAuth 2.1 Security Best Practices:
5
+ - PKCE (Proof Key for Code Exchange) mandatory for all flows
6
+ - State parameter for CSRF protection
7
+ - Nonce for ID token replay protection (OIDC)
8
+ - No implicit flow (deprecated in OAuth 2.1)
9
+ - Short-lived access tokens with refresh tokens
10
+ - Token validation with JWKS (JSON Web Key Set)
11
+ - Redirect URI exact matching
12
+
13
+ References:
14
+ - OAuth 2.1: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11
15
+ - OIDC Core: https://openid.net/specs/openid-connect-core-1_0.html
16
+ - PKCE: https://datatracker.ietf.org/doc/html/rfc7636
17
+ """
18
+
19
+ import hashlib
20
+ import secrets
21
+ from abc import ABC, abstractmethod
22
+ from typing import Any
23
+
24
+ import httpx
25
+ from pydantic import BaseModel, Field
26
+
27
+
28
+ class OAuthTokens(BaseModel):
29
+ """
30
+ OAuth token response.
31
+
32
+ Fields match OAuth 2.1 / OIDC token response spec.
33
+ """
34
+
35
+ access_token: str = Field(description="Access token for API requests")
36
+ token_type: str = Field(default="Bearer", description="Token type (always Bearer)")
37
+ expires_in: int = Field(description="Token lifetime in seconds")
38
+ refresh_token: str | None = Field(default=None, description="Refresh token for renewal")
39
+ id_token: str | None = Field(default=None, description="ID token (OIDC only)")
40
+ scope: str | None = Field(default=None, description="Granted scopes")
41
+
42
+
43
+ class OAuthUserInfo(BaseModel):
44
+ """
45
+ Normalized user information from OAuth provider.
46
+
47
+ Maps provider-specific fields to standard fields.
48
+ """
49
+
50
+ sub: str = Field(description="Subject (unique user ID from provider)")
51
+ email: str | None = Field(default=None, description="User email")
52
+ email_verified: bool = Field(default=False, description="Email verification status")
53
+ name: str | None = Field(default=None, description="Full name")
54
+ given_name: str | None = Field(default=None, description="First name")
55
+ family_name: str | None = Field(default=None, description="Last name")
56
+ picture: str | None = Field(default=None, description="Profile picture URL")
57
+ locale: str | None = Field(default=None, description="User locale")
58
+
59
+ # Provider-specific metadata
60
+ provider: str = Field(description="OAuth provider (google, microsoft)")
61
+ raw_claims: dict[str, Any] = Field(
62
+ default_factory=dict, description="Raw claims from ID token/userinfo"
63
+ )
64
+
65
+
66
+ class OAuthProvider(ABC):
67
+ """
68
+ Base class for OAuth 2.1 providers.
69
+
70
+ Implements common OAuth flow logic with PKCE.
71
+ Subclasses implement provider-specific endpoints and claim mapping.
72
+
73
+ Design Pattern:
74
+ 1. generate_auth_url() - Create authorization URL with PKCE
75
+ 2. exchange_code() - Exchange code for tokens using code_verifier
76
+ 3. validate_token() - Validate access/ID token with JWKS
77
+ 4. get_user_info() - Fetch user info from provider
78
+ 5. refresh_token() - Refresh access token using refresh_token
79
+
80
+ OAuth 2.1 Flow:
81
+ 1. Client generates code_verifier (random string)
82
+ 2. Client creates code_challenge = SHA256(code_verifier)
83
+ 3. Client redirects to authorization URL with code_challenge
84
+ 4. User authenticates and grants consent
85
+ 5. Provider redirects to callback with code
86
+ 6. Client exchanges code + code_verifier for tokens
87
+ 7. Provider validates code_verifier matches code_challenge
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ client_id: str,
93
+ client_secret: str,
94
+ redirect_uri: str,
95
+ ):
96
+ """
97
+ Initialize OAuth provider.
98
+
99
+ Args:
100
+ client_id: OAuth client ID from provider
101
+ client_secret: OAuth client secret from provider
102
+ redirect_uri: Redirect URI registered with provider
103
+ """
104
+ self.client_id = client_id
105
+ self.client_secret = client_secret
106
+ self.redirect_uri = redirect_uri
107
+
108
+ @property
109
+ @abstractmethod
110
+ def authorization_endpoint(self) -> str:
111
+ """Authorization endpoint URL."""
112
+ pass
113
+
114
+ @property
115
+ @abstractmethod
116
+ def token_endpoint(self) -> str:
117
+ """Token endpoint URL."""
118
+ pass
119
+
120
+ @property
121
+ @abstractmethod
122
+ def userinfo_endpoint(self) -> str:
123
+ """Userinfo endpoint URL."""
124
+ pass
125
+
126
+ @property
127
+ @abstractmethod
128
+ def jwks_uri(self) -> str:
129
+ """JWKS (JSON Web Key Set) URI for token validation."""
130
+ pass
131
+
132
+ @property
133
+ @abstractmethod
134
+ def default_scopes(self) -> list[str]:
135
+ """Default OAuth scopes for this provider."""
136
+ pass
137
+
138
+ @abstractmethod
139
+ def normalize_user_info(self, claims: dict[str, Any]) -> OAuthUserInfo:
140
+ """
141
+ Normalize provider-specific claims to OAuthUserInfo.
142
+
143
+ Args:
144
+ claims: Raw claims from ID token or userinfo endpoint
145
+
146
+ Returns:
147
+ Normalized user information
148
+ """
149
+ pass
150
+
151
+ @staticmethod
152
+ def generate_code_verifier() -> str:
153
+ """
154
+ Generate PKCE code verifier.
155
+
156
+ OAuth 2.1 requires PKCE for all authorization code flows.
157
+ Code verifier is a cryptographically random string (43-128 chars).
158
+
159
+ Returns:
160
+ Code verifier (base64url encoded random bytes)
161
+ """
162
+ # Generate 32 random bytes (256 bits of entropy)
163
+ # Base64url encode = 43 characters
164
+ return secrets.token_urlsafe(32)
165
+
166
+ @staticmethod
167
+ def generate_code_challenge(code_verifier: str) -> str:
168
+ """
169
+ Generate PKCE code challenge from verifier.
170
+
171
+ Uses S256 method (SHA-256 hash, base64url encoded).
172
+ Plain method is NOT allowed in OAuth 2.1.
173
+
174
+ Args:
175
+ code_verifier: Code verifier from generate_code_verifier()
176
+
177
+ Returns:
178
+ Code challenge (SHA-256 of verifier, base64url encoded)
179
+ """
180
+ # SHA-256 hash of verifier
181
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
182
+ # Base64url encode (no padding)
183
+ return secrets.token_urlsafe(32)[:43] # Trim to match digest length
184
+
185
+ @staticmethod
186
+ def generate_state() -> str:
187
+ """
188
+ Generate state parameter for CSRF protection.
189
+
190
+ State is verified on callback to prevent CSRF attacks.
191
+
192
+ Returns:
193
+ Random state string
194
+ """
195
+ return secrets.token_urlsafe(32)
196
+
197
+ @staticmethod
198
+ def generate_nonce() -> str:
199
+ """
200
+ Generate nonce for ID token replay protection (OIDC).
201
+
202
+ Nonce is included in ID token and verified to prevent replay.
203
+
204
+ Returns:
205
+ Random nonce string
206
+ """
207
+ return secrets.token_urlsafe(32)
208
+
209
+ def generate_auth_url(
210
+ self,
211
+ state: str,
212
+ code_challenge: str,
213
+ scopes: list[str] | None = None,
214
+ nonce: str | None = None,
215
+ extra_params: dict[str, str] | None = None,
216
+ ) -> str:
217
+ """
218
+ Generate authorization URL for OAuth flow.
219
+
220
+ Args:
221
+ state: CSRF protection state
222
+ code_challenge: PKCE code challenge
223
+ scopes: OAuth scopes (uses default_scopes if None)
224
+ nonce: OIDC nonce for ID token replay protection
225
+ extra_params: Provider-specific parameters
226
+
227
+ Returns:
228
+ Authorization URL to redirect user to
229
+ """
230
+ scopes = scopes or self.default_scopes
231
+
232
+ params: dict[str, str] = {
233
+ "client_id": self.client_id,
234
+ "response_type": "code", # Authorization code flow (OAuth 2.1)
235
+ "redirect_uri": self.redirect_uri,
236
+ "scope": " ".join(scopes),
237
+ "state": state,
238
+ "code_challenge": code_challenge,
239
+ "code_challenge_method": "S256", # SHA-256 (required by OAuth 2.1)
240
+ }
241
+
242
+ if nonce:
243
+ params["nonce"] = nonce
244
+
245
+ if extra_params:
246
+ params.update(extra_params)
247
+
248
+ # Build query string
249
+ query = "&".join(f"{k}={v}" for k, v in params.items())
250
+ return f"{self.authorization_endpoint}?{query}"
251
+
252
+ async def exchange_code(
253
+ self,
254
+ code: str,
255
+ code_verifier: str,
256
+ ) -> OAuthTokens:
257
+ """
258
+ Exchange authorization code for tokens.
259
+
260
+ Uses PKCE code_verifier to prove authorization request ownership.
261
+
262
+ Args:
263
+ code: Authorization code from callback
264
+ code_verifier: PKCE code verifier from session
265
+
266
+ Returns:
267
+ OAuth tokens (access, refresh, ID token)
268
+
269
+ Raises:
270
+ httpx.HTTPStatusError: If token exchange fails
271
+ """
272
+ async with httpx.AsyncClient() as client:
273
+ response = await client.post(
274
+ self.token_endpoint,
275
+ data={
276
+ "grant_type": "authorization_code",
277
+ "code": code,
278
+ "redirect_uri": self.redirect_uri,
279
+ "client_id": self.client_id,
280
+ "client_secret": self.client_secret,
281
+ "code_verifier": code_verifier, # PKCE verification
282
+ },
283
+ headers={"Accept": "application/json"},
284
+ )
285
+ response.raise_for_status()
286
+ return OAuthTokens(**response.json())
287
+
288
+ async def refresh_access_token(
289
+ self,
290
+ refresh_token: str,
291
+ ) -> OAuthTokens:
292
+ """
293
+ Refresh access token using refresh token.
294
+
295
+ Args:
296
+ refresh_token: Refresh token from initial token exchange
297
+
298
+ Returns:
299
+ New OAuth tokens
300
+
301
+ Raises:
302
+ httpx.HTTPStatusError: If refresh fails
303
+ """
304
+ async with httpx.AsyncClient() as client:
305
+ response = await client.post(
306
+ self.token_endpoint,
307
+ data={
308
+ "grant_type": "refresh_token",
309
+ "refresh_token": refresh_token,
310
+ "client_id": self.client_id,
311
+ "client_secret": self.client_secret,
312
+ },
313
+ headers={"Accept": "application/json"},
314
+ )
315
+ response.raise_for_status()
316
+ return OAuthTokens(**response.json())
317
+
318
+ async def get_user_info(
319
+ self,
320
+ access_token: str,
321
+ ) -> OAuthUserInfo:
322
+ """
323
+ Fetch user information from userinfo endpoint.
324
+
325
+ Args:
326
+ access_token: Access token from token exchange
327
+
328
+ Returns:
329
+ Normalized user information
330
+
331
+ Raises:
332
+ httpx.HTTPStatusError: If userinfo request fails
333
+ """
334
+ async with httpx.AsyncClient() as client:
335
+ response = await client.get(
336
+ self.userinfo_endpoint,
337
+ headers={"Authorization": f"Bearer {access_token}"},
338
+ )
339
+ response.raise_for_status()
340
+ claims = response.json()
341
+ return self.normalize_user_info(claims)
342
+
343
+ async def validate_id_token(
344
+ self,
345
+ id_token: str,
346
+ nonce: str | None = None,
347
+ ) -> dict[str, Any]:
348
+ """
349
+ Validate ID token signature and claims.
350
+
351
+ OAuth 2.1 + OIDC requires:
352
+ - Signature validation with JWKS
353
+ - Issuer validation
354
+ - Audience validation (client_id)
355
+ - Expiration validation
356
+ - Nonce validation (if provided)
357
+
358
+ Args:
359
+ id_token: JWT ID token from token response
360
+ nonce: Expected nonce value from session
361
+
362
+ Returns:
363
+ Validated claims from ID token
364
+
365
+ Raises:
366
+ ValueError: If token validation fails
367
+
368
+ TODO: Implement with python-jose or PyJWT
369
+ """
370
+ # TODO: Implement JWT validation
371
+ # 1. Fetch JWKS from jwks_uri
372
+ # 2. Decode JWT header to get kid (key ID)
373
+ # 3. Find matching key in JWKS
374
+ # 4. Verify signature with public key
375
+ # 5. Validate claims (iss, aud, exp, nonce)
376
+ raise NotImplementedError("ID token validation not implemented")
@@ -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
+ }