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

1""" 

2Authentication endpoints - /v1/auth/* 

3 

4Active endpoints for Stack Auth + Neon DB integration. 

5""" 

6 

7from fastapi import APIRouter, HTTPException, Depends 

8from pydantic import BaseModel, EmailStr, Field 

9from typing import Dict, Any 

10from loguru import logger 

11 

12from ..services.neon_service import neon_service 

13from ..middleware.auth import get_current_user 

14 

15router = APIRouter() 

16 

17 

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)") 

23 

24 

25class LoginRequest(BaseModel): 

26 email: EmailStr = Field(..., description="User email address") 

27 password: str = Field(..., description="Password") 

28 

29 

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)") 

33 

34 

35@router.post("/auth/register", status_code=201) 

36async def register_user(request: RegisterRequest): 

37 """ 

38 Register a new user (for testing/CLI). 

39 

40 Creates a user account and returns an API key. 

41 For production web app, use Stack Auth instead. 

42 

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 ``` 

53 

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") 

65 

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 ) 

72 

73 logger.info(f"✅ User registered: {request.email}") 

74 

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 } 

82 

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)}") 

88 

89 

90@router.post("/auth/login") 

91async def login_user(request: LoginRequest): 

92 """ 

93 Login user (for testing/CLI). 

94 

95 Authenticates user and returns API keys. 

96 For production web app, use Stack Auth instead. 

97 

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 ``` 

107 

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") 

118 

119 # Get user's existing API keys (for display) 

120 api_keys = await neon_service.list_api_keys(user["id"]) 

121 

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 ) 

131 

132 logger.info(f"✅ User logged in: {request.email}") 

133 

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 } 

145 

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") 

151 

152 

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. 

157 

158 Requires authentication via API key. 

159 

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"]) 

168 

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 } 

179 

180 

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. 

185 

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"]) 

193 

194 return { 

195 "api_keys": api_keys, 

196 "total": len(api_keys) 

197 } 

198 

199 

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. 

207 

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 ``` 

215 

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() 

222 

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 ) 

230 

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 } 

242 

243 

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. 

251 

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"]) 

259 

260 if not success: 

261 raise HTTPException(404, "API key not found") 

262 

263 return { 

264 "message": "API key revoked successfully", 

265 "key_id": key_id 

266 } 

267 

268 

269# ============================================ 

270# OAuth User Sync (GitHub, Google, etc.) 

271# ============================================ 

272 

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.)") 

279 

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 } 

289 

290 

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. 

295 

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. 

298 

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 

304 

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 ``` 

316 

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") 

323 

324 try: 

325 # Check if user already exists in public.users 

326 existing_user = await neon_service.get_user_by_id(request.user_id) 

327 

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) 

331 

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 

342 

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 } 

352 

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 } 

363 

364 response = neon_service.client.table("users").insert(user_data).execute() 

365 user = response.data[0] if response.data else user_data 

366 

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 ) 

374 

375 logger.info(f"Synced OAuth user to backend: {request.email} (provider: {request.provider})") 

376 

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 } 

386 

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 ) 

393 

394 

395# ============================================ 

396# Stack Auth User Sync 

397# ============================================ 

398 

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 

404 

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 } 

413 

414 

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. 

419 

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. 

422 

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 

428 

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 ``` 

439 

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") 

446 

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 ) 

455 

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 ) 

470 

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 ) 

493 

494 logger.info(f"Created new session key for existing user: {request.email}") 

495 

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 } 

505 

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 ) 

511 

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 ) 

519 

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 ) 

527 

528 logger.info(f"Linked existing user to Stack Auth: {request.email}") 

529 

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 } 

539 

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 ) 

551 

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 ) 

559 

560 logger.info(f"Created new Stack Auth user: {request.email}") 

561 

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 } 

571 

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 )