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

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

22 

23 

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. 

28 

29 Requires authentication via API key. 

30 

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

39 

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 } 

50 

51 

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. 

56 

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

64 

65 return { 

66 "api_keys": api_keys, 

67 "total": len(api_keys) 

68 } 

69 

70 

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. 

78 

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

86 

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

93 

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 ) 

101 

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 } 

113 

114 

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. 

122 

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

130 

131 if not success: 

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

133 

134 return { 

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

136 "key_id": key_id 

137 } 

138 

139 

140# ============================================ 

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

142# ============================================ 

143 

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

150 

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 } 

160 

161 

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. 

166 

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. 

169 

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 

175 

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

187 

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

194 

195 try: 

196 # Check if user already exists in public.users 

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

198 

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) 

202 

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 

213 

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 } 

223 

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 } 

234 

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

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

237 

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 ) 

245 

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

247 

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 } 

257 

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 ) 

264 

265 

266# ============================================ 

267# Stack Auth User Sync 

268# ============================================ 

269 

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 

275 

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 } 

284 

285 

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. 

290 

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. 

293 

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 

299 

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

310 

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

317 

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 ) 

326 

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 ) 

341 

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 ) 

364 

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

366 

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 } 

376 

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 ) 

382 

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 ) 

390 

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 ) 

398 

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

400 

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 } 

410 

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 ) 

422 

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 ) 

430 

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

432 

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 } 

442 

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 )