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
rem/api/routers/auth.py
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication Router.
|
|
3
|
+
|
|
4
|
+
Supports multiple authentication methods:
|
|
5
|
+
1. Email (passwordless): POST /api/auth/email/send-code, POST /api/auth/email/verify
|
|
6
|
+
2. Pre-approved codes: POST /api/auth/email/verify (with pre-approved code, no send-code needed)
|
|
7
|
+
3. OAuth (Google, Microsoft): GET /api/auth/{provider}/login, GET /api/auth/{provider}/callback
|
|
8
|
+
|
|
9
|
+
Endpoints:
|
|
10
|
+
- POST /api/auth/email/send-code - Send login code to email
|
|
11
|
+
- POST /api/auth/email/verify - Verify code and create session (supports pre-approved codes)
|
|
12
|
+
- GET /api/auth/{provider}/login - Initiate OAuth flow
|
|
13
|
+
- GET /api/auth/{provider}/callback - OAuth callback
|
|
14
|
+
- POST /api/auth/logout - Clear session
|
|
15
|
+
- GET /api/auth/me - Current user info
|
|
16
|
+
|
|
17
|
+
Supported providers:
|
|
18
|
+
- email: Passwordless email login
|
|
19
|
+
- preapproved: Pre-approved codes (bypass email, set via AUTH__PREAPPROVED_CODES)
|
|
20
|
+
- google: Google OAuth 2.0 / OIDC
|
|
21
|
+
- microsoft: Microsoft Entra ID OIDC
|
|
22
|
+
|
|
23
|
+
=============================================================================
|
|
24
|
+
Pre-Approved Code Authentication
|
|
25
|
+
=============================================================================
|
|
26
|
+
|
|
27
|
+
Pre-approved codes allow login without email verification. Useful for:
|
|
28
|
+
- Demo accounts
|
|
29
|
+
- Testing
|
|
30
|
+
- Beta access codes
|
|
31
|
+
- Admin provisioning
|
|
32
|
+
|
|
33
|
+
Configuration:
|
|
34
|
+
AUTH__PREAPPROVED_CODES=A12345,A67890,B11111,B22222
|
|
35
|
+
|
|
36
|
+
Code prefixes:
|
|
37
|
+
A = Admin role (e.g., A12345, AADMIN1)
|
|
38
|
+
B = Normal user role (e.g., B11111, BUSER1)
|
|
39
|
+
|
|
40
|
+
Flow:
|
|
41
|
+
1. User enters email + pre-approved code (no send-code step needed)
|
|
42
|
+
2. POST /api/auth/email/verify with email and code
|
|
43
|
+
3. System validates code against AUTH__PREAPPROVED_CODES
|
|
44
|
+
4. Creates user if not exists, sets role based on prefix
|
|
45
|
+
5. Returns JWT tokens (same as email auth)
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
curl -X POST http://localhost:8000/api/auth/email/verify \
|
|
49
|
+
-H "Content-Type: application/json" \
|
|
50
|
+
-d '{"email": "admin@example.com", "code": "A12345"}'
|
|
51
|
+
|
|
52
|
+
=============================================================================
|
|
53
|
+
Email Authentication Access Control
|
|
54
|
+
=============================================================================
|
|
55
|
+
|
|
56
|
+
The email auth provider implements a tiered access control system:
|
|
57
|
+
|
|
58
|
+
Access Control Flow (send-code):
|
|
59
|
+
User requests login code
|
|
60
|
+
├── User exists in database?
|
|
61
|
+
│ ├── Yes → Check user.tier
|
|
62
|
+
│ │ ├── tier == BLOCKED → Reject "Account is blocked"
|
|
63
|
+
│ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
|
|
64
|
+
│ └── No (new user) → Check subscriber list first
|
|
65
|
+
│ ├── Email in subscribers table? → Allow (create user & send code)
|
|
66
|
+
│ └── Not a subscriber → Check EMAIL__TRUSTED_EMAIL_DOMAINS
|
|
67
|
+
│ ├── Setting configured → domain in trusted list?
|
|
68
|
+
│ │ ├── Yes → Create user & send code
|
|
69
|
+
│ │ └── No → Reject "Email domain not allowed for signup"
|
|
70
|
+
│ └── Not configured (empty) → Create user & send code (no restrictions)
|
|
71
|
+
|
|
72
|
+
Key Behaviors:
|
|
73
|
+
- Existing users: Always allowed to login (unless tier=BLOCKED)
|
|
74
|
+
- Subscribers: Always allowed to login (regardless of email domain)
|
|
75
|
+
- New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
|
|
76
|
+
- No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
|
|
77
|
+
|
|
78
|
+
User Tiers (models.entities.UserTier):
|
|
79
|
+
- BLOCKED: Cannot login (rejected at send-code)
|
|
80
|
+
- ANONYMOUS: Rate-limited anonymous access
|
|
81
|
+
- FREE: Standard free tier
|
|
82
|
+
- BASIC/PRO: Paid tiers with additional features
|
|
83
|
+
|
|
84
|
+
Configuration:
|
|
85
|
+
# Allow only specific domains for new signups
|
|
86
|
+
EMAIL__TRUSTED_EMAIL_DOMAINS=siggymd.ai,example.com
|
|
87
|
+
|
|
88
|
+
# Allow all domains (no restrictions)
|
|
89
|
+
EMAIL__TRUSTED_EMAIL_DOMAINS=
|
|
90
|
+
|
|
91
|
+
Example blocking a user:
|
|
92
|
+
user = await user_repo.get_by_id(user_id, tenant_id="default")
|
|
93
|
+
user.tier = UserTier.BLOCKED
|
|
94
|
+
await user_repo.upsert(user)
|
|
95
|
+
|
|
96
|
+
=============================================================================
|
|
97
|
+
OAuth Design Pattern (OAuth 2.1 + PKCE)
|
|
98
|
+
=============================================================================
|
|
99
|
+
|
|
100
|
+
1. User clicks "Login with Google"
|
|
101
|
+
2. /login generates state + PKCE code_verifier
|
|
102
|
+
3. Store code_verifier in session
|
|
103
|
+
4. Redirect to provider with code_challenge
|
|
104
|
+
5. User authenticates and grants consent
|
|
105
|
+
6. Provider redirects to /callback with code
|
|
106
|
+
7. Exchange code + code_verifier for tokens
|
|
107
|
+
8. Validate ID token signature with JWKS
|
|
108
|
+
9. Store user info in session
|
|
109
|
+
10. Redirect to application
|
|
110
|
+
|
|
111
|
+
Dependencies:
|
|
112
|
+
pip install authlib httpx
|
|
113
|
+
|
|
114
|
+
Environment variables:
|
|
115
|
+
AUTH__ENABLED=true
|
|
116
|
+
AUTH__SESSION_SECRET=<random-secret>
|
|
117
|
+
AUTH__GOOGLE__CLIENT_ID=<google-client-id>
|
|
118
|
+
AUTH__GOOGLE__CLIENT_SECRET=<google-client-secret>
|
|
119
|
+
AUTH__MICROSOFT__CLIENT_ID=<microsoft-client-id>
|
|
120
|
+
AUTH__MICROSOFT__CLIENT_SECRET=<microsoft-client-secret>
|
|
121
|
+
AUTH__MICROSOFT__TENANT=common
|
|
122
|
+
EMAIL__TRUSTED_EMAIL_DOMAINS=example.com # Optional: restrict new signups
|
|
123
|
+
|
|
124
|
+
References:
|
|
125
|
+
- Authlib: https://docs.authlib.org/en/latest/
|
|
126
|
+
- OAuth 2.1: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
130
|
+
from fastapi.responses import RedirectResponse
|
|
131
|
+
from authlib.integrations.starlette_client import OAuth
|
|
132
|
+
from pydantic import BaseModel, EmailStr
|
|
133
|
+
from loguru import logger
|
|
134
|
+
|
|
135
|
+
from .common import ErrorResponse
|
|
136
|
+
|
|
137
|
+
from ...settings import settings
|
|
138
|
+
from ...services.postgres.service import PostgresService
|
|
139
|
+
from ...services.user_service import UserService
|
|
140
|
+
from ...auth.providers.email import EmailAuthProvider
|
|
141
|
+
from ...auth.jwt import JWTService, get_jwt_service
|
|
142
|
+
from ...utils.user_id import email_to_user_id
|
|
143
|
+
|
|
144
|
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
145
|
+
|
|
146
|
+
# Initialize Authlib OAuth client
|
|
147
|
+
# Authlib handles PKCE, state, nonce, token validation automatically
|
|
148
|
+
oauth = OAuth()
|
|
149
|
+
|
|
150
|
+
# Register Google provider
|
|
151
|
+
if settings.auth.google.client_id:
|
|
152
|
+
oauth.register(
|
|
153
|
+
name="google",
|
|
154
|
+
client_id=settings.auth.google.client_id,
|
|
155
|
+
client_secret=settings.auth.google.client_secret,
|
|
156
|
+
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
157
|
+
client_kwargs={
|
|
158
|
+
"scope": "openid email profile",
|
|
159
|
+
# Authlib automatically adds PKCE to authorization request
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
logger.info("Google OAuth provider registered")
|
|
163
|
+
|
|
164
|
+
# Register Microsoft provider
|
|
165
|
+
if settings.auth.microsoft.client_id:
|
|
166
|
+
tenant = settings.auth.microsoft.tenant
|
|
167
|
+
oauth.register(
|
|
168
|
+
name="microsoft",
|
|
169
|
+
client_id=settings.auth.microsoft.client_id,
|
|
170
|
+
client_secret=settings.auth.microsoft.client_secret,
|
|
171
|
+
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
|
|
172
|
+
client_kwargs={
|
|
173
|
+
"scope": "openid email profile User.Read",
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
logger.info(f"Microsoft OAuth provider registered (tenant: {tenant})")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# =============================================================================
|
|
180
|
+
# Email Authentication Endpoints
|
|
181
|
+
# =============================================================================
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class EmailSendCodeRequest(BaseModel):
|
|
185
|
+
"""Request to send login code."""
|
|
186
|
+
email: EmailStr
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class EmailVerifyRequest(BaseModel):
|
|
190
|
+
"""Request to verify login code."""
|
|
191
|
+
email: EmailStr
|
|
192
|
+
code: str
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.post(
|
|
196
|
+
"/email/send-code",
|
|
197
|
+
responses={
|
|
198
|
+
400: {"model": ErrorResponse, "description": "Invalid request or email rejected"},
|
|
199
|
+
500: {"model": ErrorResponse, "description": "Failed to send login code"},
|
|
200
|
+
501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
async def send_email_code(request: Request, body: EmailSendCodeRequest):
|
|
204
|
+
"""
|
|
205
|
+
Send a login code to an email address.
|
|
206
|
+
|
|
207
|
+
Creates user if not exists (using deterministic UUID from email).
|
|
208
|
+
Stores code in user metadata with expiry.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
request: FastAPI request
|
|
212
|
+
body: EmailSendCodeRequest with email
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Success status and message
|
|
216
|
+
"""
|
|
217
|
+
if not settings.email.is_configured:
|
|
218
|
+
raise HTTPException(
|
|
219
|
+
status_code=501,
|
|
220
|
+
detail="Email authentication is not configured"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Get database connection
|
|
224
|
+
if not settings.postgres.enabled:
|
|
225
|
+
raise HTTPException(
|
|
226
|
+
status_code=501,
|
|
227
|
+
detail="Database is required for email authentication"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
db = PostgresService()
|
|
231
|
+
try:
|
|
232
|
+
await db.connect()
|
|
233
|
+
|
|
234
|
+
# Initialize email auth provider
|
|
235
|
+
email_auth = EmailAuthProvider()
|
|
236
|
+
|
|
237
|
+
# Send code
|
|
238
|
+
result = await email_auth.send_code(
|
|
239
|
+
email=body.email,
|
|
240
|
+
db=db,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if result.success:
|
|
244
|
+
return {
|
|
245
|
+
"success": True,
|
|
246
|
+
"message": result.message,
|
|
247
|
+
"email": result.email,
|
|
248
|
+
}
|
|
249
|
+
else:
|
|
250
|
+
raise HTTPException(
|
|
251
|
+
status_code=400,
|
|
252
|
+
detail=result.message or result.error
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
except HTTPException:
|
|
256
|
+
raise
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.error(f"Error sending login code: {e}")
|
|
259
|
+
raise HTTPException(status_code=500, detail="Failed to send login code")
|
|
260
|
+
finally:
|
|
261
|
+
await db.disconnect()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@router.post(
|
|
265
|
+
"/email/verify",
|
|
266
|
+
responses={
|
|
267
|
+
400: {"model": ErrorResponse, "description": "Invalid or expired code"},
|
|
268
|
+
500: {"model": ErrorResponse, "description": "Failed to verify login code"},
|
|
269
|
+
501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
273
|
+
"""
|
|
274
|
+
Verify login code and create session with JWT tokens.
|
|
275
|
+
|
|
276
|
+
Supports two authentication methods:
|
|
277
|
+
1. Pre-approved codes: Codes from AUTH__PREAPPROVED_CODES bypass email verification.
|
|
278
|
+
- A prefix = admin role, B prefix = normal user role
|
|
279
|
+
- Creates user if not exists, logs in directly
|
|
280
|
+
2. Email verification: Standard 6-digit code sent via email
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
request: FastAPI request
|
|
284
|
+
body: EmailVerifyRequest with email and code
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Success status with user info and JWT tokens
|
|
288
|
+
"""
|
|
289
|
+
if not settings.postgres.enabled:
|
|
290
|
+
raise HTTPException(
|
|
291
|
+
status_code=501,
|
|
292
|
+
detail="Database is required for email authentication"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
db = PostgresService()
|
|
296
|
+
try:
|
|
297
|
+
await db.connect()
|
|
298
|
+
user_service = UserService(db)
|
|
299
|
+
|
|
300
|
+
# Check for pre-approved code first
|
|
301
|
+
preapproved = settings.auth.check_preapproved_code(body.code)
|
|
302
|
+
if preapproved:
|
|
303
|
+
logger.info(f"Pre-approved code login attempt for {body.email} (role: {preapproved['role']})")
|
|
304
|
+
|
|
305
|
+
# Get or create user with pre-approved role
|
|
306
|
+
user_id = email_to_user_id(body.email)
|
|
307
|
+
user_entity = await user_service.get_user_by_id(user_id)
|
|
308
|
+
|
|
309
|
+
if not user_entity:
|
|
310
|
+
# Create new user with role from pre-approved code
|
|
311
|
+
user_entity = await user_service.get_or_create_user(
|
|
312
|
+
email=body.email,
|
|
313
|
+
name=body.email.split("@")[0],
|
|
314
|
+
tenant_id="default",
|
|
315
|
+
)
|
|
316
|
+
# Update role based on pre-approved code prefix
|
|
317
|
+
user_entity.role = preapproved["role"]
|
|
318
|
+
from ...services.postgres.repository import Repository
|
|
319
|
+
from ...models.entities.user import User
|
|
320
|
+
user_repo = Repository(User, "users", db=db)
|
|
321
|
+
await user_repo.upsert(user_entity)
|
|
322
|
+
logger.info(f"Created user {body.email} with role={preapproved['role']} via pre-approved code")
|
|
323
|
+
else:
|
|
324
|
+
# Update existing user's role if admin code used
|
|
325
|
+
if preapproved["role"] == "admin" and user_entity.role != "admin":
|
|
326
|
+
user_entity.role = "admin"
|
|
327
|
+
from ...services.postgres.repository import Repository
|
|
328
|
+
from ...models.entities.user import User
|
|
329
|
+
user_repo = Repository(User, "users", db=db)
|
|
330
|
+
await user_repo.upsert(user_entity)
|
|
331
|
+
logger.info(f"Upgraded user {body.email} to admin via pre-approved code")
|
|
332
|
+
|
|
333
|
+
# Build user dict for session/JWT
|
|
334
|
+
user_dict = {
|
|
335
|
+
"id": str(user_entity.id),
|
|
336
|
+
"email": body.email,
|
|
337
|
+
"email_verified": True,
|
|
338
|
+
"name": user_entity.name or body.email.split("@")[0],
|
|
339
|
+
"provider": "preapproved",
|
|
340
|
+
"tenant_id": user_entity.tenant_id or "default",
|
|
341
|
+
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
342
|
+
"role": user_entity.role or preapproved["role"],
|
|
343
|
+
"roles": [user_entity.role or preapproved["role"]],
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# Generate JWT tokens
|
|
347
|
+
jwt_service = get_jwt_service()
|
|
348
|
+
tokens = jwt_service.create_tokens(user_dict)
|
|
349
|
+
|
|
350
|
+
# Store user in session
|
|
351
|
+
request.session["user"] = user_dict
|
|
352
|
+
|
|
353
|
+
logger.info(f"User authenticated via pre-approved code: {body.email} (role: {user_dict['role']})")
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
"success": True,
|
|
357
|
+
"message": "Successfully authenticated with pre-approved code!",
|
|
358
|
+
"user": user_dict,
|
|
359
|
+
"access_token": tokens["access_token"],
|
|
360
|
+
"refresh_token": tokens["refresh_token"],
|
|
361
|
+
"token_type": tokens["token_type"],
|
|
362
|
+
"expires_in": tokens["expires_in"],
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# Standard email verification flow
|
|
366
|
+
if not settings.email.is_configured:
|
|
367
|
+
raise HTTPException(
|
|
368
|
+
status_code=501,
|
|
369
|
+
detail="Email authentication is not configured"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Initialize email auth provider
|
|
373
|
+
email_auth = EmailAuthProvider()
|
|
374
|
+
|
|
375
|
+
# Verify code
|
|
376
|
+
result = await email_auth.verify_code(
|
|
377
|
+
email=body.email,
|
|
378
|
+
code=body.code,
|
|
379
|
+
db=db,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if not result.success:
|
|
383
|
+
raise HTTPException(
|
|
384
|
+
status_code=400,
|
|
385
|
+
detail=result.message or result.error
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Create session - compatible with OAuth session format
|
|
389
|
+
user_dict = email_auth.get_user_dict(
|
|
390
|
+
email=result.email,
|
|
391
|
+
user_id=result.user_id,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Fetch actual user data from database to get role/tier
|
|
395
|
+
try:
|
|
396
|
+
user_entity = await user_service.get_user_by_id(result.user_id)
|
|
397
|
+
if user_entity:
|
|
398
|
+
# Override defaults with actual database values
|
|
399
|
+
user_dict["role"] = user_entity.role or "user"
|
|
400
|
+
user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
|
|
401
|
+
user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
|
|
402
|
+
user_dict["name"] = user_entity.name or user_dict["name"]
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.warning(f"Could not fetch user details: {e}")
|
|
405
|
+
# Continue with defaults from get_user_dict
|
|
406
|
+
|
|
407
|
+
# Generate JWT tokens
|
|
408
|
+
jwt_service = get_jwt_service()
|
|
409
|
+
tokens = jwt_service.create_tokens(user_dict)
|
|
410
|
+
|
|
411
|
+
# Store user in session (for backward compatibility)
|
|
412
|
+
request.session["user"] = user_dict
|
|
413
|
+
|
|
414
|
+
logger.info(f"User authenticated via email: {result.email}")
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
"success": True,
|
|
418
|
+
"message": result.message,
|
|
419
|
+
"user": user_dict,
|
|
420
|
+
# JWT tokens for stateless auth
|
|
421
|
+
"access_token": tokens["access_token"],
|
|
422
|
+
"refresh_token": tokens["refresh_token"],
|
|
423
|
+
"token_type": tokens["token_type"],
|
|
424
|
+
"expires_in": tokens["expires_in"],
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
except HTTPException:
|
|
428
|
+
raise
|
|
429
|
+
except Exception as e:
|
|
430
|
+
logger.error(f"Error verifying login code: {e}")
|
|
431
|
+
raise HTTPException(status_code=500, detail="Failed to verify login code")
|
|
432
|
+
finally:
|
|
433
|
+
await db.disconnect()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# =============================================================================
|
|
437
|
+
# OAuth Authentication Endpoints
|
|
438
|
+
# =============================================================================
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@router.get(
|
|
442
|
+
"/{provider}/login",
|
|
443
|
+
responses={
|
|
444
|
+
400: {"model": ErrorResponse, "description": "Unknown OAuth provider"},
|
|
445
|
+
501: {"model": ErrorResponse, "description": "Authentication is disabled"},
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
async def login(provider: str, request: Request):
|
|
449
|
+
"""
|
|
450
|
+
Initiate OAuth flow with provider.
|
|
451
|
+
|
|
452
|
+
Authlib automatically:
|
|
453
|
+
- Generates state for CSRF protection
|
|
454
|
+
- Generates PKCE code_verifier and code_challenge
|
|
455
|
+
- Stores state and code_verifier in session
|
|
456
|
+
- Redirects to provider's authorization endpoint
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
provider: OAuth provider (google, microsoft)
|
|
460
|
+
request: FastAPI request (for session access)
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Redirect to provider's authorization page
|
|
464
|
+
"""
|
|
465
|
+
if not settings.auth.enabled:
|
|
466
|
+
raise HTTPException(status_code=501, detail="Authentication is disabled")
|
|
467
|
+
|
|
468
|
+
# Get OAuth client for provider
|
|
469
|
+
client = oauth.create_client(provider)
|
|
470
|
+
if not client:
|
|
471
|
+
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}")
|
|
472
|
+
|
|
473
|
+
# Get redirect URI from settings
|
|
474
|
+
if provider == "google":
|
|
475
|
+
redirect_uri = settings.auth.google.redirect_uri
|
|
476
|
+
elif provider == "microsoft":
|
|
477
|
+
redirect_uri = settings.auth.microsoft.redirect_uri
|
|
478
|
+
else:
|
|
479
|
+
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}")
|
|
480
|
+
|
|
481
|
+
# Authlib authorize_redirect() automatically:
|
|
482
|
+
# - Generates state parameter
|
|
483
|
+
# - Generates PKCE code_verifier and code_challenge
|
|
484
|
+
# - Stores state and code_verifier in session
|
|
485
|
+
# - Builds authorization URL with all required parameters
|
|
486
|
+
return await client.authorize_redirect(request, redirect_uri)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@router.get(
|
|
490
|
+
"/{provider}/callback",
|
|
491
|
+
responses={
|
|
492
|
+
400: {"model": ErrorResponse, "description": "Authentication failed or unknown provider"},
|
|
493
|
+
501: {"model": ErrorResponse, "description": "Authentication is disabled"},
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
async def callback(provider: str, request: Request):
|
|
497
|
+
"""
|
|
498
|
+
OAuth callback endpoint.
|
|
499
|
+
|
|
500
|
+
Authlib automatically:
|
|
501
|
+
- Validates state parameter (CSRF protection)
|
|
502
|
+
- Exchanges code for tokens with PKCE code_verifier
|
|
503
|
+
- Validates ID token signature with JWKS
|
|
504
|
+
- Verifies ID token claims (iss, aud, exp, nonce)
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
provider: OAuth provider (google, microsoft)
|
|
508
|
+
request: FastAPI request (for session and query params)
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Redirect to application home page
|
|
512
|
+
"""
|
|
513
|
+
if not settings.auth.enabled:
|
|
514
|
+
raise HTTPException(status_code=501, detail="Authentication is disabled")
|
|
515
|
+
|
|
516
|
+
# Get OAuth client for provider
|
|
517
|
+
client = oauth.create_client(provider)
|
|
518
|
+
if not client:
|
|
519
|
+
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}")
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
# Authlib authorize_access_token() automatically:
|
|
523
|
+
# - Validates state from session (CSRF)
|
|
524
|
+
# - Retrieves code_verifier from session
|
|
525
|
+
# - Exchanges authorization code for tokens
|
|
526
|
+
# - Validates ID token signature with JWKS
|
|
527
|
+
# - Verifies ID token claims
|
|
528
|
+
token = await client.authorize_access_token(request)
|
|
529
|
+
|
|
530
|
+
# Parse user info from ID token or call userinfo endpoint
|
|
531
|
+
# Authlib parses ID token claims automatically
|
|
532
|
+
user_info = token.get("userinfo")
|
|
533
|
+
if not user_info:
|
|
534
|
+
# Fetch from userinfo endpoint if not in ID token
|
|
535
|
+
user_info = await client.userinfo(token=token)
|
|
536
|
+
|
|
537
|
+
# --- REM Integration Start ---
|
|
538
|
+
if settings.postgres.enabled:
|
|
539
|
+
# Connect to DB
|
|
540
|
+
db = PostgresService()
|
|
541
|
+
try:
|
|
542
|
+
await db.connect()
|
|
543
|
+
user_service = UserService(db)
|
|
544
|
+
|
|
545
|
+
# Get/Create User
|
|
546
|
+
user_entity = await user_service.get_or_create_user(
|
|
547
|
+
email=user_info.get("email"),
|
|
548
|
+
name=user_info.get("name", "New User"),
|
|
549
|
+
avatar_url=user_info.get("picture"),
|
|
550
|
+
tenant_id="default", # Single tenant for now
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# Link Anonymous Session
|
|
554
|
+
# TrackingMiddleware sets request.state.anon_id
|
|
555
|
+
anon_id = getattr(request.state, "anon_id", None)
|
|
556
|
+
# Fallback to cookie if middleware didn't run or state missing
|
|
557
|
+
if not anon_id:
|
|
558
|
+
# Attempt to parse cookie manually if needed, but middleware
|
|
559
|
+
# usually handles the signature logic.
|
|
560
|
+
# Just check raw cookie for simple case (not recommended if signed)
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
if anon_id:
|
|
564
|
+
await user_service.link_anonymous_session(user_entity, anon_id)
|
|
565
|
+
|
|
566
|
+
# Enrich session user with DB info
|
|
567
|
+
# user_id = UUID5 hash of email (deterministic, bijection)
|
|
568
|
+
db_info = {
|
|
569
|
+
"id": email_to_user_id(user_info.get("email")),
|
|
570
|
+
"tenant_id": user_entity.tenant_id,
|
|
571
|
+
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
572
|
+
"roles": [user_entity.role] if user_entity.role else [],
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
except Exception as db_e:
|
|
576
|
+
logger.error(f"Database error during auth callback: {db_e}")
|
|
577
|
+
# Continue login even if DB fails, but warn
|
|
578
|
+
db_info = {"id": "db_error", "tier": "free"}
|
|
579
|
+
finally:
|
|
580
|
+
await db.disconnect()
|
|
581
|
+
else:
|
|
582
|
+
db_info = {"id": "no_db", "tier": "free"}
|
|
583
|
+
# --- REM Integration End ---
|
|
584
|
+
|
|
585
|
+
# Store user info in session
|
|
586
|
+
request.session["user"] = {
|
|
587
|
+
"provider": provider,
|
|
588
|
+
"sub": user_info.get("sub"),
|
|
589
|
+
"email": user_info.get("email"),
|
|
590
|
+
"name": user_info.get("name"),
|
|
591
|
+
"picture": user_info.get("picture"),
|
|
592
|
+
# Add DB info
|
|
593
|
+
"id": db_info.get("id"),
|
|
594
|
+
"tenant_id": db_info.get("tenant_id", "default"),
|
|
595
|
+
"tier": db_info.get("tier"),
|
|
596
|
+
"roles": db_info.get("roles", []),
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
# Store tokens in session for API access
|
|
600
|
+
request.session["tokens"] = {
|
|
601
|
+
"access_token": token.get("access_token"),
|
|
602
|
+
"refresh_token": token.get("refresh_token"),
|
|
603
|
+
"expires_at": token.get("expires_at"),
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
logger.info(f"User authenticated: {user_info.get('email')} via {provider}")
|
|
607
|
+
|
|
608
|
+
# Redirect to application
|
|
609
|
+
# TODO: Support custom redirect URL from state parameter
|
|
610
|
+
return RedirectResponse(url="/")
|
|
611
|
+
|
|
612
|
+
except Exception as e:
|
|
613
|
+
logger.error(f"OAuth callback error: {e}")
|
|
614
|
+
raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@router.post("/logout")
|
|
618
|
+
async def logout(request: Request):
|
|
619
|
+
"""
|
|
620
|
+
Clear user session.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
request: FastAPI request
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
Success message
|
|
627
|
+
"""
|
|
628
|
+
request.session.clear()
|
|
629
|
+
return {"message": "Logged out successfully"}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@router.get(
|
|
633
|
+
"/me",
|
|
634
|
+
responses={
|
|
635
|
+
401: {"model": ErrorResponse, "description": "Not authenticated"},
|
|
636
|
+
},
|
|
637
|
+
)
|
|
638
|
+
async def me(request: Request):
|
|
639
|
+
"""
|
|
640
|
+
Get current user information from session or JWT.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
request: FastAPI request
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
User information or 401 if not authenticated
|
|
647
|
+
"""
|
|
648
|
+
# First check for JWT in Authorization header
|
|
649
|
+
auth_header = request.headers.get("Authorization")
|
|
650
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
651
|
+
token = auth_header[7:]
|
|
652
|
+
jwt_service = get_jwt_service()
|
|
653
|
+
user = jwt_service.verify_token(token)
|
|
654
|
+
if user:
|
|
655
|
+
return user
|
|
656
|
+
|
|
657
|
+
# Fall back to session
|
|
658
|
+
user = request.session.get("user")
|
|
659
|
+
if not user:
|
|
660
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
661
|
+
|
|
662
|
+
return user
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# =============================================================================
|
|
666
|
+
# JWT Token Endpoints
|
|
667
|
+
# =============================================================================
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class TokenRefreshRequest(BaseModel):
|
|
671
|
+
"""Request to refresh access token."""
|
|
672
|
+
refresh_token: str
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@router.post(
|
|
676
|
+
"/token/refresh",
|
|
677
|
+
responses={
|
|
678
|
+
401: {"model": ErrorResponse, "description": "Invalid or expired refresh token"},
|
|
679
|
+
},
|
|
680
|
+
)
|
|
681
|
+
async def refresh_token(body: TokenRefreshRequest):
|
|
682
|
+
"""
|
|
683
|
+
Refresh access token using refresh token.
|
|
684
|
+
|
|
685
|
+
Fetches the user's current role/tier from the database to ensure
|
|
686
|
+
the new access token reflects their actual permissions.
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
body: TokenRefreshRequest with refresh_token
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
New access token or 401 if refresh token is invalid
|
|
693
|
+
"""
|
|
694
|
+
jwt_service = get_jwt_service()
|
|
695
|
+
|
|
696
|
+
# First decode the refresh token to get user_id (without full verification yet)
|
|
697
|
+
payload = jwt_service.decode_without_verification(body.refresh_token)
|
|
698
|
+
if not payload:
|
|
699
|
+
raise HTTPException(
|
|
700
|
+
status_code=401,
|
|
701
|
+
detail="Invalid refresh token format"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
user_id = payload.get("sub")
|
|
705
|
+
if not user_id:
|
|
706
|
+
raise HTTPException(
|
|
707
|
+
status_code=401,
|
|
708
|
+
detail="Invalid refresh token: missing user ID"
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# Fetch user from database to get current role/tier
|
|
712
|
+
user_override = None
|
|
713
|
+
if settings.postgres.enabled:
|
|
714
|
+
db = PostgresService()
|
|
715
|
+
try:
|
|
716
|
+
await db.connect()
|
|
717
|
+
user_service = UserService(db)
|
|
718
|
+
user_entity = await user_service.get_user_by_id(user_id)
|
|
719
|
+
if user_entity:
|
|
720
|
+
user_override = {
|
|
721
|
+
"role": user_entity.role or "user",
|
|
722
|
+
"roles": [user_entity.role] if user_entity.role else ["user"],
|
|
723
|
+
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
724
|
+
"name": user_entity.name,
|
|
725
|
+
}
|
|
726
|
+
logger.debug(f"Refresh token: fetched user {user_id} with role={user_override['role']}, tier={user_override['tier']}")
|
|
727
|
+
except Exception as e:
|
|
728
|
+
logger.warning(f"Could not fetch user for token refresh: {e}")
|
|
729
|
+
# Continue without override - will use defaults
|
|
730
|
+
finally:
|
|
731
|
+
await db.disconnect()
|
|
732
|
+
|
|
733
|
+
# Now do the actual refresh with proper verification
|
|
734
|
+
result = jwt_service.refresh_access_token(body.refresh_token, user_override=user_override)
|
|
735
|
+
|
|
736
|
+
if not result:
|
|
737
|
+
raise HTTPException(
|
|
738
|
+
status_code=401,
|
|
739
|
+
detail="Invalid or expired refresh token"
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
return result
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@router.post(
|
|
746
|
+
"/token/verify",
|
|
747
|
+
responses={
|
|
748
|
+
401: {"model": ErrorResponse, "description": "Missing, invalid, or expired token"},
|
|
749
|
+
},
|
|
750
|
+
)
|
|
751
|
+
async def verify_token(request: Request):
|
|
752
|
+
"""
|
|
753
|
+
Verify an access token is valid.
|
|
754
|
+
|
|
755
|
+
Pass the token in the Authorization header: Bearer <token>
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
User info if valid, 401 if invalid
|
|
759
|
+
"""
|
|
760
|
+
auth_header = request.headers.get("Authorization")
|
|
761
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
762
|
+
raise HTTPException(
|
|
763
|
+
status_code=401,
|
|
764
|
+
detail="Missing Authorization header"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
token = auth_header[7:]
|
|
768
|
+
jwt_service = get_jwt_service()
|
|
769
|
+
user = jwt_service.verify_token(token)
|
|
770
|
+
|
|
771
|
+
if not user:
|
|
772
|
+
raise HTTPException(
|
|
773
|
+
status_code=401,
|
|
774
|
+
detail="Invalid or expired token"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
return {"valid": True, "user": user}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# =============================================================================
|
|
781
|
+
# Development Token Endpoints (non-production only)
|
|
782
|
+
# =============================================================================
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def generate_dev_token() -> str:
|
|
786
|
+
"""
|
|
787
|
+
Generate a dev token for testing.
|
|
788
|
+
|
|
789
|
+
Token format: dev_<hmac_signature>
|
|
790
|
+
The signature is based on the session secret to ensure only valid tokens work.
|
|
791
|
+
"""
|
|
792
|
+
import hashlib
|
|
793
|
+
import hmac
|
|
794
|
+
|
|
795
|
+
# Use session secret as key
|
|
796
|
+
secret = settings.auth.session_secret or "dev-secret"
|
|
797
|
+
message = "test-user:dev-token"
|
|
798
|
+
|
|
799
|
+
signature = hmac.new(
|
|
800
|
+
secret.encode(),
|
|
801
|
+
message.encode(),
|
|
802
|
+
hashlib.sha256
|
|
803
|
+
).hexdigest()[:32]
|
|
804
|
+
|
|
805
|
+
return f"dev_{signature}"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def verify_dev_token(token: str) -> bool:
|
|
809
|
+
"""Verify a dev token is valid."""
|
|
810
|
+
expected = generate_dev_token()
|
|
811
|
+
return token == expected
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@router.get(
|
|
815
|
+
"/dev/token",
|
|
816
|
+
responses={
|
|
817
|
+
401: {"model": ErrorResponse, "description": "Dev tokens not available in production"},
|
|
818
|
+
},
|
|
819
|
+
)
|
|
820
|
+
async def get_dev_token(request: Request):
|
|
821
|
+
"""
|
|
822
|
+
Get a development token for testing (non-production only).
|
|
823
|
+
|
|
824
|
+
This token can be used as a Bearer token to authenticate as the
|
|
825
|
+
test user (test-user / test@rem.local) without going through OAuth.
|
|
826
|
+
|
|
827
|
+
Usage:
|
|
828
|
+
curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/...
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
401 if in production environment
|
|
832
|
+
Token and usage instructions otherwise
|
|
833
|
+
"""
|
|
834
|
+
if settings.environment == "production":
|
|
835
|
+
raise HTTPException(
|
|
836
|
+
status_code=401,
|
|
837
|
+
detail="Dev tokens are not available in production"
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
token = generate_dev_token()
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
"token": token,
|
|
844
|
+
"type": "Bearer",
|
|
845
|
+
"user": {
|
|
846
|
+
"id": "test-user",
|
|
847
|
+
"email": "test@rem.local",
|
|
848
|
+
"name": "Test User",
|
|
849
|
+
},
|
|
850
|
+
"usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
|
|
851
|
+
"warning": "This token is for development/testing only and will not work in production.",
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@router.get(
|
|
856
|
+
"/dev/mock-code/{email}",
|
|
857
|
+
responses={
|
|
858
|
+
401: {"model": ErrorResponse, "description": "Mock codes not available in production"},
|
|
859
|
+
404: {"model": ErrorResponse, "description": "No code found for email"},
|
|
860
|
+
},
|
|
861
|
+
)
|
|
862
|
+
async def get_mock_code(email: str, request: Request):
|
|
863
|
+
"""
|
|
864
|
+
Get the mock login code for testing (non-production only).
|
|
865
|
+
|
|
866
|
+
This endpoint retrieves the code that was "sent" via email in mock mode.
|
|
867
|
+
Use this for automated testing without real email delivery.
|
|
868
|
+
|
|
869
|
+
Usage:
|
|
870
|
+
1. POST /api/auth/email/send-code with email
|
|
871
|
+
2. GET /api/auth/dev/mock-code/{email} to retrieve the code
|
|
872
|
+
3. POST /api/auth/email/verify with email and code
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
401 if in production environment
|
|
876
|
+
404 if no code found for the email
|
|
877
|
+
The code and email otherwise
|
|
878
|
+
"""
|
|
879
|
+
if settings.environment == "production":
|
|
880
|
+
raise HTTPException(
|
|
881
|
+
status_code=401,
|
|
882
|
+
detail="Mock codes are not available in production"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
from ...services.email import EmailService
|
|
886
|
+
|
|
887
|
+
code = EmailService.get_mock_code(email)
|
|
888
|
+
if not code:
|
|
889
|
+
raise HTTPException(
|
|
890
|
+
status_code=404,
|
|
891
|
+
detail=f"No mock code found for {email}. Send a code first."
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
"email": email,
|
|
896
|
+
"code": code,
|
|
897
|
+
"warning": "This endpoint is for testing only and will not work in production.",
|
|
898
|
+
}
|