Coverage for src/alprina_cli/api/routes/auth.py: 0%
95 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-12 18:07 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-12 18:07 +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 CreateAPIKeyRequest(BaseModel):
20 name: str = Field(default="API Key", description="Name for the API key")
21 expires_days: int | None = Field(default=None, description="Expiration in days (optional)")
24@router.get("/auth/me")
25async def get_current_user_info(user: Dict[str, Any] = Depends(get_current_user)):
26 """
27 Get current user information.
29 Requires authentication via API key.
31 **Example:**
32 ```bash
33 curl https://api.alprina.com/v1/auth/me \\
34 -H "Authorization: Bearer alprina_sk_..."
35 ```
36 """
37 # Get usage stats
38 stats = await neon_service.get_user_stats(user["id"])
40 return {
41 "user": {
42 "id": user["id"],
43 "email": user["email"],
44 "full_name": user["full_name"],
45 "tier": user["tier"],
46 "created_at": user["created_at"]
47 },
48 "usage": stats
49 }
52@router.get("/auth/api-keys")
53async def list_api_keys(user: Dict[str, Any] = Depends(get_current_user)):
54 """
55 List all API keys for current user.
57 **Example:**
58 ```bash
59 curl https://api.alprina.com/v1/auth/api-keys \\
60 -H "Authorization: Bearer alprina_sk_..."
61 ```
62 """
63 api_keys = await neon_service.list_api_keys(user["id"])
65 return {
66 "api_keys": api_keys,
67 "total": len(api_keys)
68 }
71@router.post("/auth/api-keys", status_code=201)
72async def create_api_key(
73 request: CreateAPIKeyRequest,
74 user: Dict[str, Any] = Depends(get_current_user)
75):
76 """
77 Create a new API key.
79 **Example:**
80 ```bash
81 curl -X POST https://api.alprina.com/v1/auth/api-keys \\
82 -H "Authorization: Bearer alprina_sk_..." \\
83 -H "Content-Type: application/json" \\
84 -d '{"name": "Production API Key", "expires_days": 365}'
85 ```
87 **Response:**
88 - Returns the NEW API key
89 - Save it - it won't be shown again!
90 """
91 # Generate new key
92 api_key = neon_service.generate_api_key()
94 # Store in database
95 key_data = await neon_service.create_api_key(
96 user_id=user["id"],
97 api_key=api_key,
98 name=request.name,
99 expires_days=request.expires_days
100 )
102 return {
103 "api_key": api_key,
104 "key_info": {
105 "id": key_data["id"],
106 "name": key_data["name"],
107 "key_prefix": key_data["key_prefix"],
108 "created_at": key_data["created_at"],
109 "expires_at": key_data["expires_at"]
110 },
111 "message": "API key created successfully. Save it securely - it won't be shown again!"
112 }
115@router.delete("/auth/api-keys/{key_id}")
116async def revoke_api_key(
117 key_id: str,
118 user: Dict[str, Any] = Depends(get_current_user)
119):
120 """
121 Revoke (deactivate) an API key.
123 **Example:**
124 ```bash
125 curl -X DELETE https://api.alprina.com/v1/auth/api-keys/{key_id} \\
126 -H "Authorization: Bearer alprina_sk_..."
127 ```
128 """
129 success = await neon_service.deactivate_api_key(key_id, user["id"])
131 if not success:
132 raise HTTPException(404, "API key not found")
134 return {
135 "message": "API key revoked successfully",
136 "key_id": key_id
137 }
140# ============================================
141# OAuth User Sync (GitHub, Google, etc.)
142# ============================================
144class SyncOAuthUserRequest(BaseModel):
145 """Request to sync an OAuth user to backend database."""
146 user_id: str = Field(..., description="OAuth provider user ID")
147 email: EmailStr
148 full_name: str | None = None
149 provider: str = Field(default="github", description="OAuth provider (github, google, etc.)")
151 class Config:
152 schema_extra = {
153 "example": {
154 "user_id": "123e4567-e89b-12d3-a456-426614174000",
155 "email": "user@example.com",
156 "full_name": "John Doe",
157 "provider": "github"
158 }
159 }
162@router.post("/auth/sync-oauth-user", status_code=201)
163async def sync_oauth_user(request: SyncOAuthUserRequest):
164 """
165 Sync an OAuth user to Neon database.
167 This endpoint is called after a user signs up via GitHub/Google OAuth
168 to create their profile in the backend database and generate an API key.
170 **Flow:**
171 1. User signs in with GitHub OAuth → Creates user record
172 2. Frontend calls this endpoint to sync to Neon DB
173 3. Backend creates API key for the user
174 4. User can now use the platform
176 **Example:**
177 ```bash
178 curl -X POST https://api.alprina.com/v1/auth/sync-oauth-user \\
179 -H "Content-Type: application/json" \\
180 -d '{
181 "user_id": "oauth-user-uuid",
182 "email": "user@example.com",
183 "full_name": "John Doe",
184 "provider": "github"
185 }'
186 ```
188 **Response:**
189 - Returns user info and API key
190 - If user already exists, returns existing data
191 """
192 if not neon_service.is_enabled():
193 raise HTTPException(503, "Database not configured")
195 try:
196 # Check if user already exists in public.users
197 existing_user = await neon_service.get_user_by_id(request.user_id)
199 if existing_user:
200 # User already synced, just get their API keys
201 api_keys = await neon_service.list_api_keys(request.user_id)
203 # Get or create a session key
204 if not api_keys:
205 session_key = neon_service.generate_api_key()
206 await neon_service.create_api_key(
207 user_id=request.user_id,
208 api_key=session_key,
209 name="OAuth Session"
210 )
211 else:
212 session_key = None # We don't store full keys, only prefixes
214 return {
215 "user_id": existing_user["id"],
216 "email": existing_user["email"],
217 "full_name": existing_user.get("full_name"),
218 "tier": existing_user.get("tier", "free"),
219 "api_key": session_key, # Will be None if keys already exist
220 "message": "User already exists",
221 "is_new": False
222 }
224 # Create new user in public.users
225 # NOTE: No free tier - user must choose a plan
226 user_data = {
227 "id": request.user_id, # Use same ID as OAuth provider
228 "email": request.email,
229 "full_name": request.full_name,
230 "tier": "none", # No plan selected yet
231 "requests_per_hour": 0, # Must subscribe to use
232 "scans_per_month": 0 # Must subscribe to use
233 }
235 response = neon_service.client.table("users").insert(user_data).execute()
236 user = response.data[0] if response.data else user_data
238 # Generate API key for CLI/API use
239 api_key = neon_service.generate_api_key()
240 await neon_service.create_api_key(
241 user_id=request.user_id,
242 api_key=api_key,
243 name=f"{request.provider.title()} OAuth"
244 )
246 logger.info(f"Synced OAuth user to backend: {request.email} (provider: {request.provider})")
248 return {
249 "user_id": user["id"],
250 "email": user["email"],
251 "full_name": user.get("full_name"),
252 "tier": user.get("tier", "free"),
253 "api_key": api_key,
254 "message": "OAuth user synced successfully",
255 "is_new": True
256 }
258 except Exception as e:
259 logger.error(f"Failed to sync OAuth user: {e}")
260 raise HTTPException(
261 status_code=500,
262 detail=f"Failed to sync OAuth user: {str(e)}"
263 )
266# ============================================
267# Stack Auth User Sync
268# ============================================
270class SyncStackUserRequest(BaseModel):
271 """Request to sync a Stack Auth user to backend database."""
272 stack_user_id: str = Field(..., description="Stack Auth user ID")
273 email: EmailStr
274 full_name: str | None = None
276 class Config:
277 schema_extra = {
278 "example": {
279 "stack_user_id": "stack_user_123abc",
280 "email": "user@example.com",
281 "full_name": "John Doe"
282 }
283 }
286@router.post("/auth/sync-stack-user", status_code=201)
287async def sync_stack_user(request: SyncStackUserRequest):
288 """
289 Sync a Stack Auth user to Neon database.
291 This endpoint is called after a user signs in via Stack Auth
292 to create their profile in the backend database and generate an API key.
294 **Flow:**
295 1. User signs in with Stack Auth → Stack creates user record
296 2. Frontend calls this endpoint to sync to Neon DB
297 3. Backend creates/updates user and API key
298 4. User can now use the platform
300 **Example:**
301 ```bash
302 curl -X POST https://api.alprina.com/v1/auth/sync-stack-user \\
303 -H "Content-Type: application/json" \\
304 -d '{
305 "stack_user_id": "stack_user_123abc",
306 "email": "user@example.com",
307 "full_name": "John Doe"
308 }'
309 ```
311 **Response:**
312 - Returns user info and API key
313 - If user already exists, returns existing data
314 """
315 if not neon_service.is_enabled():
316 raise HTTPException(503, "Database not configured")
318 try:
319 # Check if user already exists by stack_user_id
320 pool = await neon_service.get_pool()
321 async with pool.acquire() as conn:
322 existing_user = await conn.fetchrow(
323 "SELECT * FROM users WHERE stack_user_id = $1",
324 request.stack_user_id
325 )
327 if existing_user:
328 # User already synced - check if they have an active session key
329 existing_keys = await conn.fetch(
330 """
331 SELECT key_hash, key_prefix FROM api_keys
332 WHERE user_id = $1
333 AND is_active = true
334 AND name = 'Stack Auth Web Session'
335 AND (expires_at IS NULL OR expires_at > NOW())
336 ORDER BY created_at DESC
337 LIMIT 1
338 """,
339 existing_user['id']
340 )
342 # If they have an active session key, don't return it (we can't retrieve the full key)
343 # Frontend should use the key already in localStorage
344 # Only create a new key if they have no active session keys at all
345 if existing_keys:
346 logger.info(f"Stack user already exists with active session: {request.email}")
347 return {
348 "user_id": str(existing_user["id"]),
349 "email": existing_user["email"],
350 "full_name": existing_user.get("full_name"),
351 "tier": existing_user.get("tier", "none"),
352 "api_key": None, # Don't create new key - use existing from localStorage
353 "message": "User already has active session",
354 "is_new": False
355 }
356 else:
357 # No active session key - create one (first login or all keys revoked)
358 session_key = neon_service.generate_api_key()
359 await neon_service.create_api_key(
360 user_id=str(existing_user['id']),
361 api_key=session_key,
362 name="Stack Auth Web Session"
363 )
365 logger.info(f"Created new session key for existing user: {request.email}")
367 return {
368 "user_id": str(existing_user["id"]),
369 "email": existing_user["email"],
370 "full_name": existing_user.get("full_name"),
371 "tier": existing_user.get("tier", "none"),
372 "api_key": session_key,
373 "message": "New session created",
374 "is_new": False
375 }
377 # Check if user exists by email (migration case)
378 existing_by_email = await conn.fetchrow(
379 "SELECT * FROM users WHERE email = $1",
380 request.email
381 )
383 if existing_by_email:
384 # Update existing user with stack_user_id
385 await conn.execute(
386 "UPDATE users SET stack_user_id = $1 WHERE email = $2",
387 request.stack_user_id,
388 request.email
389 )
391 # Create a fresh session key for web use
392 session_key = neon_service.generate_api_key()
393 await neon_service.create_api_key(
394 user_id=str(existing_by_email['id']),
395 api_key=session_key,
396 name="Stack Auth Web Session"
397 )
399 logger.info(f"Linked existing user to Stack Auth: {request.email}")
401 return {
402 "user_id": str(existing_by_email["id"]),
403 "email": existing_by_email["email"],
404 "full_name": existing_by_email.get("full_name"),
405 "tier": existing_by_email.get("tier", "none"),
406 "api_key": session_key,
407 "message": "Existing user linked to Stack Auth",
408 "is_new": False
409 }
411 # Create new user in Neon DB
412 new_user = await conn.fetchrow(
413 """
414 INSERT INTO users (email, full_name, stack_user_id, tier)
415 VALUES ($1, $2, $3, 'none')
416 RETURNING id, email, full_name, tier, created_at
417 """,
418 request.email,
419 request.full_name,
420 request.stack_user_id
421 )
423 # Generate API key for CLI/API use
424 api_key = neon_service.generate_api_key()
425 await neon_service.create_api_key(
426 user_id=str(new_user['id']),
427 api_key=api_key,
428 name="Stack Auth"
429 )
431 logger.info(f"Created new Stack Auth user: {request.email}")
433 return {
434 "user_id": str(new_user["id"]),
435 "email": new_user["email"],
436 "full_name": new_user.get("full_name"),
437 "tier": new_user.get("tier", "none"),
438 "api_key": api_key,
439 "message": "Stack Auth user synced successfully",
440 "is_new": True
441 }
443 except Exception as e:
444 logger.error(f"Failed to sync Stack Auth user: {e}")
445 raise HTTPException(
446 status_code=500,
447 detail=f"Failed to sync Stack Auth user: {str(e)}"
448 )