Coverage for src/alprina_cli/api/routes/auth.py: 33%
133 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Authentication endpoints - /v1/auth/*
4Active endpoints for Stack Auth + Neon DB integration.
5"""
7from fastapi import APIRouter, HTTPException, Depends
8from pydantic import BaseModel, EmailStr, Field
9from typing import Dict, Any
10from loguru import logger
12from ..services.neon_service import neon_service
13from ..middleware.auth import get_current_user
15router = APIRouter()
18# Request/Response Models
19class RegisterRequest(BaseModel):
20 email: EmailStr = Field(..., description="User email address")
21 password: str = Field(..., min_length=8, description="Password (min 8 characters)")
22 full_name: str | None = Field(default=None, description="Full name (optional)")
25class LoginRequest(BaseModel):
26 email: EmailStr = Field(..., description="User email address")
27 password: str = Field(..., description="Password")
30class CreateAPIKeyRequest(BaseModel):
31 name: str = Field(default="API Key", description="Name for the API key")
32 expires_days: int | None = Field(default=None, description="Expiration in days (optional)")
35@router.post("/auth/register", status_code=201)
36async def register_user(request: RegisterRequest):
37 """
38 Register a new user (for testing/CLI).
40 Creates a user account and returns an API key.
41 For production web app, use Stack Auth instead.
43 **Example:**
44 ```bash
45 curl -X POST https://api.alprina.com/v1/auth/register \\
46 -H "Content-Type: application/json" \\
47 -d '{
48 "email": "user@example.com",
49 "password": "SecurePass123!",
50 "full_name": "John Doe"
51 }'
52 ```
54 **Returns:**
55 - user_id: Unique user identifier
56 - email: User email
57 - api_key: Generated API key for authentication
58 - tier: Subscription tier (default: "none")
59 """
60 try:
61 # Check if user already exists
62 existing_user = await neon_service.get_user_by_email(request.email)
63 if existing_user:
64 raise HTTPException(status_code=400, detail="User with this email already exists")
66 # Create user in Neon database (includes API key generation)
67 user_data = await neon_service.create_user(
68 email=request.email,
69 password=request.password,
70 full_name=request.full_name
71 )
73 logger.info(f"✅ User registered: {request.email}")
75 return {
76 "user_id": user_data["user_id"],
77 "email": user_data["email"],
78 "api_key": user_data["api_key"],
79 "tier": user_data["tier"],
80 "message": "User registered successfully"
81 }
83 except HTTPException:
84 raise
85 except Exception as e:
86 logger.error(f"Registration error: {e}")
87 raise HTTPException(status_code=500, detail=f"Registration failed: {str(e)}")
90@router.post("/auth/login")
91async def login_user(request: LoginRequest):
92 """
93 Login user (for testing/CLI).
95 Authenticates user and returns API keys.
96 For production web app, use Stack Auth instead.
98 **Example:**
99 ```bash
100 curl -X POST https://api.alprina.com/v1/auth/login \\
101 -H "Content-Type: application/json" \\
102 -d '{
103 "email": "user@example.com",
104 "password": "SecurePass123!"
105 }'
106 ```
108 **Returns:**
109 - user: User information
110 - api_keys: List of active API keys
111 - session_key: Primary API key for immediate use
112 """
113 try:
114 # Authenticate user (verifies email and password)
115 user = await neon_service.authenticate_user(request.email, request.password)
116 if not user:
117 raise HTTPException(status_code=401, detail="Invalid email or password")
119 # Get user's existing API keys (for display)
120 api_keys = await neon_service.list_api_keys(user["id"])
122 # Always create a new session key for login (we can't retrieve existing full keys)
123 from datetime import datetime, timedelta
124 api_key = neon_service.generate_api_key()
125 await neon_service.create_api_key(
126 user_id=user["id"],
127 api_key=api_key,
128 name="Login Session",
129 expires_at=datetime.now() + timedelta(days=90)
130 )
132 logger.info(f"✅ User logged in: {request.email}")
134 return {
135 "user": {
136 "id": user["id"],
137 "email": user["email"],
138 "full_name": user.get("full_name"),
139 "tier": user.get("tier", "none"),
140 "created_at": str(user.get("created_at")) if user.get("created_at") else None
141 },
142 "existing_api_keys": len(api_keys),
143 "session_key": api_key
144 }
146 except HTTPException:
147 raise
148 except Exception as e:
149 logger.error(f"Login error: {e}")
150 raise HTTPException(status_code=401, detail="Invalid email or password")
153@router.get("/auth/me")
154async def get_current_user_info(user: Dict[str, Any] = Depends(get_current_user)):
155 """
156 Get current user information.
158 Requires authentication via API key.
160 **Example:**
161 ```bash
162 curl https://api.alprina.com/v1/auth/me \\
163 -H "Authorization: Bearer alprina_sk_..."
164 ```
165 """
166 # Get usage stats
167 stats = await neon_service.get_user_stats(user["id"])
169 return {
170 "user": {
171 "id": user["id"],
172 "email": user["email"],
173 "full_name": user["full_name"],
174 "tier": user["tier"],
175 "created_at": user["created_at"]
176 },
177 "usage": stats
178 }
181@router.get("/auth/api-keys")
182async def list_api_keys(user: Dict[str, Any] = Depends(get_current_user)):
183 """
184 List all API keys for current user.
186 **Example:**
187 ```bash
188 curl https://api.alprina.com/v1/auth/api-keys \\
189 -H "Authorization: Bearer alprina_sk_..."
190 ```
191 """
192 api_keys = await neon_service.list_api_keys(user["id"])
194 return {
195 "api_keys": api_keys,
196 "total": len(api_keys)
197 }
200@router.post("/auth/api-keys", status_code=201)
201async def create_api_key(
202 request: CreateAPIKeyRequest,
203 user: Dict[str, Any] = Depends(get_current_user)
204):
205 """
206 Create a new API key.
208 **Example:**
209 ```bash
210 curl -X POST https://api.alprina.com/v1/auth/api-keys \\
211 -H "Authorization: Bearer alprina_sk_..." \\
212 -H "Content-Type: application/json" \\
213 -d '{"name": "Production API Key", "expires_days": 365}'
214 ```
216 **Response:**
217 - Returns the NEW API key
218 - Save it - it won't be shown again!
219 """
220 # Generate new key
221 api_key = neon_service.generate_api_key()
223 # Store in database
224 key_data = await neon_service.create_api_key(
225 user_id=user["id"],
226 api_key=api_key,
227 name=request.name,
228 expires_days=request.expires_days
229 )
231 return {
232 "api_key": api_key,
233 "key_info": {
234 "id": key_data["id"],
235 "name": key_data["name"],
236 "key_prefix": key_data["key_prefix"],
237 "created_at": key_data["created_at"],
238 "expires_at": key_data["expires_at"]
239 },
240 "message": "API key created successfully. Save it securely - it won't be shown again!"
241 }
244@router.delete("/auth/api-keys/{key_id}")
245async def revoke_api_key(
246 key_id: str,
247 user: Dict[str, Any] = Depends(get_current_user)
248):
249 """
250 Revoke (deactivate) an API key.
252 **Example:**
253 ```bash
254 curl -X DELETE https://api.alprina.com/v1/auth/api-keys/{key_id} \\
255 -H "Authorization: Bearer alprina_sk_..."
256 ```
257 """
258 success = await neon_service.deactivate_api_key(key_id, user["id"])
260 if not success:
261 raise HTTPException(404, "API key not found")
263 return {
264 "message": "API key revoked successfully",
265 "key_id": key_id
266 }
269# ============================================
270# OAuth User Sync (GitHub, Google, etc.)
271# ============================================
273class SyncOAuthUserRequest(BaseModel):
274 """Request to sync an OAuth user to backend database."""
275 user_id: str = Field(..., description="OAuth provider user ID")
276 email: EmailStr
277 full_name: str | None = None
278 provider: str = Field(default="github", description="OAuth provider (github, google, etc.)")
280 class Config:
281 schema_extra = {
282 "example": {
283 "user_id": "123e4567-e89b-12d3-a456-426614174000",
284 "email": "user@example.com",
285 "full_name": "John Doe",
286 "provider": "github"
287 }
288 }
291@router.post("/auth/sync-oauth-user", status_code=201)
292async def sync_oauth_user(request: SyncOAuthUserRequest):
293 """
294 Sync an OAuth user to Neon database.
296 This endpoint is called after a user signs up via GitHub/Google OAuth
297 to create their profile in the backend database and generate an API key.
299 **Flow:**
300 1. User signs in with GitHub OAuth → Creates user record
301 2. Frontend calls this endpoint to sync to Neon DB
302 3. Backend creates API key for the user
303 4. User can now use the platform
305 **Example:**
306 ```bash
307 curl -X POST https://api.alprina.com/v1/auth/sync-oauth-user \\
308 -H "Content-Type: application/json" \\
309 -d '{
310 "user_id": "oauth-user-uuid",
311 "email": "user@example.com",
312 "full_name": "John Doe",
313 "provider": "github"
314 }'
315 ```
317 **Response:**
318 - Returns user info and API key
319 - If user already exists, returns existing data
320 """
321 if not neon_service.is_enabled():
322 raise HTTPException(503, "Database not configured")
324 try:
325 # Check if user already exists in public.users
326 existing_user = await neon_service.get_user_by_id(request.user_id)
328 if existing_user:
329 # User already synced, just get their API keys
330 api_keys = await neon_service.list_api_keys(request.user_id)
332 # Get or create a session key
333 if not api_keys:
334 session_key = neon_service.generate_api_key()
335 await neon_service.create_api_key(
336 user_id=request.user_id,
337 api_key=session_key,
338 name="OAuth Session"
339 )
340 else:
341 session_key = None # We don't store full keys, only prefixes
343 return {
344 "user_id": existing_user["id"],
345 "email": existing_user["email"],
346 "full_name": existing_user.get("full_name"),
347 "tier": existing_user.get("tier", "free"),
348 "api_key": session_key, # Will be None if keys already exist
349 "message": "User already exists",
350 "is_new": False
351 }
353 # Create new user in public.users
354 # NOTE: No free tier - user must choose a plan
355 user_data = {
356 "id": request.user_id, # Use same ID as OAuth provider
357 "email": request.email,
358 "full_name": request.full_name,
359 "tier": "none", # No plan selected yet
360 "requests_per_hour": 0, # Must subscribe to use
361 "scans_per_month": 0 # Must subscribe to use
362 }
364 response = neon_service.client.table("users").insert(user_data).execute()
365 user = response.data[0] if response.data else user_data
367 # Generate API key for CLI/API use
368 api_key = neon_service.generate_api_key()
369 await neon_service.create_api_key(
370 user_id=request.user_id,
371 api_key=api_key,
372 name=f"{request.provider.title()} OAuth"
373 )
375 logger.info(f"Synced OAuth user to backend: {request.email} (provider: {request.provider})")
377 return {
378 "user_id": user["id"],
379 "email": user["email"],
380 "full_name": user.get("full_name"),
381 "tier": user.get("tier", "free"),
382 "api_key": api_key,
383 "message": "OAuth user synced successfully",
384 "is_new": True
385 }
387 except Exception as e:
388 logger.error(f"Failed to sync OAuth user: {e}")
389 raise HTTPException(
390 status_code=500,
391 detail=f"Failed to sync OAuth user: {str(e)}"
392 )
395# ============================================
396# Stack Auth User Sync
397# ============================================
399class SyncStackUserRequest(BaseModel):
400 """Request to sync a Stack Auth user to backend database."""
401 stack_user_id: str = Field(..., description="Stack Auth user ID")
402 email: EmailStr
403 full_name: str | None = None
405 class Config:
406 schema_extra = {
407 "example": {
408 "stack_user_id": "stack_user_123abc",
409 "email": "user@example.com",
410 "full_name": "John Doe"
411 }
412 }
415@router.post("/auth/sync-stack-user", status_code=201)
416async def sync_stack_user(request: SyncStackUserRequest):
417 """
418 Sync a Stack Auth user to Neon database.
420 This endpoint is called after a user signs in via Stack Auth
421 to create their profile in the backend database and generate an API key.
423 **Flow:**
424 1. User signs in with Stack Auth → Stack creates user record
425 2. Frontend calls this endpoint to sync to Neon DB
426 3. Backend creates/updates user and API key
427 4. User can now use the platform
429 **Example:**
430 ```bash
431 curl -X POST https://api.alprina.com/v1/auth/sync-stack-user \\
432 -H "Content-Type: application/json" \\
433 -d '{
434 "stack_user_id": "stack_user_123abc",
435 "email": "user@example.com",
436 "full_name": "John Doe"
437 }'
438 ```
440 **Response:**
441 - Returns user info and API key
442 - If user already exists, returns existing data
443 """
444 if not neon_service.is_enabled():
445 raise HTTPException(503, "Database not configured")
447 try:
448 # Check if user already exists by stack_user_id
449 pool = await neon_service.get_pool()
450 async with pool.acquire() as conn:
451 existing_user = await conn.fetchrow(
452 "SELECT * FROM users WHERE stack_user_id = $1",
453 request.stack_user_id
454 )
456 if existing_user:
457 # User already synced - check if they have an active session key
458 existing_keys = await conn.fetch(
459 """
460 SELECT key_hash, key_prefix FROM api_keys
461 WHERE user_id = $1
462 AND is_active = true
463 AND name = 'Stack Auth Web Session'
464 AND (expires_at IS NULL OR expires_at > NOW())
465 ORDER BY created_at DESC
466 LIMIT 1
467 """,
468 existing_user['id']
469 )
471 # If they have an active session key, don't return it (we can't retrieve the full key)
472 # Frontend should use the key already in localStorage
473 # Only create a new key if they have no active session keys at all
474 if existing_keys:
475 logger.info(f"Stack user already exists with active session: {request.email}")
476 return {
477 "user_id": str(existing_user["id"]),
478 "email": existing_user["email"],
479 "full_name": existing_user.get("full_name"),
480 "tier": existing_user.get("tier", "none"),
481 "api_key": None, # Don't create new key - use existing from localStorage
482 "message": "User already has active session",
483 "is_new": False
484 }
485 else:
486 # No active session key - create one (first login or all keys revoked)
487 session_key = neon_service.generate_api_key()
488 await neon_service.create_api_key(
489 user_id=str(existing_user['id']),
490 api_key=session_key,
491 name="Stack Auth Web Session"
492 )
494 logger.info(f"Created new session key for existing user: {request.email}")
496 return {
497 "user_id": str(existing_user["id"]),
498 "email": existing_user["email"],
499 "full_name": existing_user.get("full_name"),
500 "tier": existing_user.get("tier", "none"),
501 "api_key": session_key,
502 "message": "New session created",
503 "is_new": False
504 }
506 # Check if user exists by email (migration case)
507 existing_by_email = await conn.fetchrow(
508 "SELECT * FROM users WHERE email = $1",
509 request.email
510 )
512 if existing_by_email:
513 # Update existing user with stack_user_id
514 await conn.execute(
515 "UPDATE users SET stack_user_id = $1 WHERE email = $2",
516 request.stack_user_id,
517 request.email
518 )
520 # Create a fresh session key for web use
521 session_key = neon_service.generate_api_key()
522 await neon_service.create_api_key(
523 user_id=str(existing_by_email['id']),
524 api_key=session_key,
525 name="Stack Auth Web Session"
526 )
528 logger.info(f"Linked existing user to Stack Auth: {request.email}")
530 return {
531 "user_id": str(existing_by_email["id"]),
532 "email": existing_by_email["email"],
533 "full_name": existing_by_email.get("full_name"),
534 "tier": existing_by_email.get("tier", "none"),
535 "api_key": session_key,
536 "message": "Existing user linked to Stack Auth",
537 "is_new": False
538 }
540 # Create new user in Neon DB
541 new_user = await conn.fetchrow(
542 """
543 INSERT INTO users (email, full_name, stack_user_id, tier)
544 VALUES ($1, $2, $3, 'none')
545 RETURNING id, email, full_name, tier, created_at
546 """,
547 request.email,
548 request.full_name,
549 request.stack_user_id
550 )
552 # Generate API key for CLI/API use
553 api_key = neon_service.generate_api_key()
554 await neon_service.create_api_key(
555 user_id=str(new_user['id']),
556 api_key=api_key,
557 name="Stack Auth"
558 )
560 logger.info(f"Created new Stack Auth user: {request.email}")
562 return {
563 "user_id": str(new_user["id"]),
564 "email": new_user["email"],
565 "full_name": new_user.get("full_name"),
566 "tier": new_user.get("tier", "none"),
567 "api_key": api_key,
568 "message": "Stack Auth user synced successfully",
569 "is_new": True
570 }
572 except Exception as e:
573 logger.error(f"Failed to sync Stack Auth user: {e}")
574 raise HTTPException(
575 status_code=500,
576 detail=f"Failed to sync Stack Auth user: {str(e)}"
577 )