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.
- rem/__init__.py +129 -0
- rem/agentic/README.md +760 -0
- rem/agentic/__init__.py +54 -0
- rem/agentic/agents/README.md +155 -0
- rem/agentic/agents/__init__.py +38 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +425 -0
- rem/agentic/context_builder.py +360 -0
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/__init__.py +0 -0
- rem/agentic/mcp/tool_wrapper.py +273 -0
- rem/agentic/otel/__init__.py +5 -0
- rem/agentic/otel/setup.py +240 -0
- rem/agentic/providers/phoenix.py +926 -0
- rem/agentic/providers/pydantic_ai.py +854 -0
- rem/agentic/query.py +117 -0
- rem/agentic/query_helper.py +89 -0
- rem/agentic/schema.py +737 -0
- rem/agentic/serialization.py +245 -0
- rem/agentic/tools/__init__.py +5 -0
- rem/agentic/tools/rem_tools.py +242 -0
- rem/api/README.md +657 -0
- rem/api/deps.py +253 -0
- rem/api/main.py +460 -0
- rem/api/mcp_router/prompts.py +182 -0
- rem/api/mcp_router/resources.py +820 -0
- rem/api/mcp_router/server.py +243 -0
- rem/api/mcp_router/tools.py +1605 -0
- rem/api/middleware/tracking.py +172 -0
- rem/api/routers/admin.py +520 -0
- rem/api/routers/auth.py +898 -0
- rem/api/routers/chat/__init__.py +5 -0
- rem/api/routers/chat/child_streaming.py +394 -0
- rem/api/routers/chat/completions.py +702 -0
- rem/api/routers/chat/json_utils.py +76 -0
- rem/api/routers/chat/models.py +202 -0
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +546 -0
- rem/api/routers/chat/streaming.py +950 -0
- rem/api/routers/chat/streaming_utils.py +327 -0
- rem/api/routers/common.py +18 -0
- rem/api/routers/dev.py +87 -0
- rem/api/routers/feedback.py +276 -0
- rem/api/routers/messages.py +620 -0
- rem/api/routers/models.py +86 -0
- rem/api/routers/query.py +362 -0
- rem/api/routers/shared_sessions.py +422 -0
- rem/auth/README.md +258 -0
- rem/auth/__init__.py +36 -0
- rem/auth/jwt.py +367 -0
- rem/auth/middleware.py +318 -0
- rem/auth/providers/__init__.py +16 -0
- rem/auth/providers/base.py +376 -0
- rem/auth/providers/email.py +215 -0
- rem/auth/providers/google.py +163 -0
- rem/auth/providers/microsoft.py +237 -0
- rem/cli/README.md +517 -0
- rem/cli/__init__.py +8 -0
- rem/cli/commands/README.md +299 -0
- rem/cli/commands/__init__.py +3 -0
- rem/cli/commands/ask.py +549 -0
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +495 -0
- rem/cli/commands/db.py +828 -0
- rem/cli/commands/dreaming.py +324 -0
- rem/cli/commands/experiments.py +1698 -0
- rem/cli/commands/mcp.py +66 -0
- rem/cli/commands/process.py +388 -0
- rem/cli/commands/query.py +109 -0
- rem/cli/commands/scaffold.py +47 -0
- rem/cli/commands/schema.py +230 -0
- rem/cli/commands/serve.py +106 -0
- rem/cli/commands/session.py +453 -0
- rem/cli/dreaming.py +363 -0
- rem/cli/main.py +123 -0
- rem/config.py +244 -0
- rem/mcp_server.py +41 -0
- rem/models/core/__init__.py +49 -0
- rem/models/core/core_model.py +70 -0
- rem/models/core/engram.py +333 -0
- rem/models/core/experiment.py +672 -0
- rem/models/core/inline_edge.py +132 -0
- rem/models/core/rem_query.py +246 -0
- rem/models/entities/__init__.py +68 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/file.py +57 -0
- rem/models/entities/image_resource.py +88 -0
- rem/models/entities/message.py +64 -0
- rem/models/entities/moment.py +123 -0
- rem/models/entities/ontology.py +181 -0
- rem/models/entities/ontology_config.py +131 -0
- rem/models/entities/resource.py +95 -0
- rem/models/entities/schema.py +87 -0
- rem/models/entities/session.py +84 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +93 -0
- rem/py.typed +0 -0
- rem/registry.py +373 -0
- rem/schemas/README.md +507 -0
- rem/schemas/__init__.py +6 -0
- rem/schemas/agents/README.md +92 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/core/moment-builder.yaml +178 -0
- rem/schemas/agents/core/rem-query-agent.yaml +226 -0
- rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
- rem/schemas/agents/core/simple-assistant.yaml +19 -0
- rem/schemas/agents/core/user-profile-builder.yaml +163 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
- rem/schemas/agents/examples/contract-extractor.yaml +134 -0
- rem/schemas/agents/examples/cv-parser.yaml +263 -0
- rem/schemas/agents/examples/hello-world.yaml +37 -0
- rem/schemas/agents/examples/query.yaml +54 -0
- rem/schemas/agents/examples/simple.yaml +21 -0
- rem/schemas/agents/examples/test.yaml +29 -0
- rem/schemas/agents/rem.yaml +132 -0
- rem/schemas/evaluators/hello-world/default.yaml +77 -0
- rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
- rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
- rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
- rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
- rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
- rem/services/__init__.py +18 -0
- rem/services/audio/INTEGRATION.md +308 -0
- rem/services/audio/README.md +376 -0
- rem/services/audio/__init__.py +15 -0
- rem/services/audio/chunker.py +354 -0
- rem/services/audio/transcriber.py +259 -0
- rem/services/content/README.md +1269 -0
- rem/services/content/__init__.py +5 -0
- rem/services/content/providers.py +760 -0
- rem/services/content/service.py +762 -0
- rem/services/dreaming/README.md +230 -0
- rem/services/dreaming/__init__.py +53 -0
- rem/services/dreaming/affinity_service.py +322 -0
- rem/services/dreaming/moment_service.py +251 -0
- rem/services/dreaming/ontology_service.py +54 -0
- rem/services/dreaming/user_model_service.py +297 -0
- rem/services/dreaming/utils.py +39 -0
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +522 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/__init__.py +11 -0
- rem/services/embeddings/api.py +127 -0
- rem/services/embeddings/worker.py +435 -0
- rem/services/fs/README.md +662 -0
- rem/services/fs/__init__.py +62 -0
- rem/services/fs/examples.py +206 -0
- rem/services/fs/examples_paths.py +204 -0
- rem/services/fs/git_provider.py +935 -0
- rem/services/fs/local_provider.py +760 -0
- rem/services/fs/parsing-hooks-examples.md +172 -0
- rem/services/fs/paths.py +276 -0
- rem/services/fs/provider.py +460 -0
- rem/services/fs/s3_provider.py +1042 -0
- rem/services/fs/service.py +186 -0
- rem/services/git/README.md +1075 -0
- rem/services/git/__init__.py +17 -0
- rem/services/git/service.py +469 -0
- rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
- rem/services/phoenix/README.md +453 -0
- rem/services/phoenix/__init__.py +46 -0
- rem/services/phoenix/client.py +960 -0
- rem/services/phoenix/config.py +88 -0
- rem/services/phoenix/prompt_labels.py +477 -0
- rem/services/postgres/README.md +757 -0
- rem/services/postgres/__init__.py +49 -0
- rem/services/postgres/diff_service.py +599 -0
- rem/services/postgres/migration_service.py +427 -0
- rem/services/postgres/programmable_diff_service.py +635 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
- rem/services/postgres/register_type.py +353 -0
- rem/services/postgres/repository.py +481 -0
- rem/services/postgres/schema_generator.py +661 -0
- rem/services/postgres/service.py +802 -0
- rem/services/postgres/sql_builder.py +355 -0
- rem/services/rate_limit.py +113 -0
- rem/services/rem/README.md +318 -0
- rem/services/rem/__init__.py +23 -0
- rem/services/rem/exceptions.py +71 -0
- rem/services/rem/executor.py +293 -0
- rem/services/rem/parser.py +180 -0
- rem/services/rem/queries.py +196 -0
- rem/services/rem/query.py +371 -0
- rem/services/rem/service.py +608 -0
- rem/services/session/README.md +374 -0
- rem/services/session/__init__.py +13 -0
- rem/services/session/compression.py +488 -0
- rem/services/session/pydantic_messages.py +310 -0
- rem/services/session/reload.py +85 -0
- rem/services/user_service.py +130 -0
- rem/settings.py +1877 -0
- rem/sql/background_indexes.sql +52 -0
- rem/sql/migrations/001_install.sql +983 -0
- rem/sql/migrations/002_install_models.sql +3157 -0
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +282 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/AGENTIC_CHUNKING.md +597 -0
- rem/utils/README.md +628 -0
- rem/utils/__init__.py +61 -0
- rem/utils/agentic_chunking.py +622 -0
- rem/utils/batch_ops.py +343 -0
- rem/utils/chunking.py +108 -0
- rem/utils/clip_embeddings.py +276 -0
- rem/utils/constants.py +97 -0
- rem/utils/date_utils.py +228 -0
- rem/utils/dict_utils.py +98 -0
- rem/utils/embeddings.py +436 -0
- rem/utils/examples/embeddings_example.py +305 -0
- rem/utils/examples/sql_types_example.py +202 -0
- rem/utils/files.py +323 -0
- rem/utils/markdown.py +16 -0
- rem/utils/mime_types.py +158 -0
- rem/utils/model_helpers.py +492 -0
- rem/utils/schema_loader.py +649 -0
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +350 -0
- rem/utils/user_id.py +81 -0
- rem/utils/vision.py +325 -0
- rem/workers/README.md +506 -0
- rem/workers/__init__.py +7 -0
- rem/workers/db_listener.py +579 -0
- rem/workers/db_maintainer.py +74 -0
- rem/workers/dreaming.py +502 -0
- rem/workers/engram_processor.py +312 -0
- rem/workers/sqs_file_processor.py +193 -0
- rem/workers/unlogged_maintainer.py +463 -0
- remdb-0.3.242.dist-info/METADATA +1632 -0
- remdb-0.3.242.dist-info/RECORD +235 -0
- remdb-0.3.242.dist-info/WHEEL +4 -0
- 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
|
+
}
|