Coverage for src/alprina_cli/api/routes/device_auth.py: 21%

138 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2Device Authorization Flow - OAuth for CLI 

3Similar to GitHub CLI, Vercel CLI, etc. 

4""" 

5 

6from fastapi import APIRouter, HTTPException, Depends, Header 

7from pydantic import BaseModel 

8from typing import Dict, Any, List, Optional 

9from datetime import datetime, timedelta 

10 

11from ..services.neon_service import neon_service 

12from ..middleware.auth import get_current_user 

13 

14router = APIRouter() 

15 

16 

17class DeviceAuthResponse(BaseModel): 

18 device_code: str 

19 user_code: str 

20 verification_url: str 

21 expires_in: int = 900 # 15 minutes 

22 interval: int = 5 # Poll every 5 seconds 

23 

24 

25class DeviceTokenRequest(BaseModel): 

26 device_code: str 

27 

28 

29class AuthorizeDeviceRequest(BaseModel): 

30 user_code: str 

31 stack_user_id: str | None = None 

32 email: str | None = None 

33 full_name: str | None = None 

34 

35 

36class CLICodeRequest(BaseModel): 

37 cli_code: str 

38 

39 

40class DeviceInfo(BaseModel): 

41 id: str 

42 name: str 

43 created_at: datetime 

44 last_used: Optional[datetime] 

45 status: str # active, revoked 

46 

47 

48@router.post("/auth/device", response_model=DeviceAuthResponse) 

49async def request_device_authorization(): 

50 """ 

51 Step 1: CLI requests device authorization. 

52 

53 Returns device_code and user_code. 

54 CLI will poll /auth/device/token with device_code. 

55 User will visit verification_url and enter user_code. 

56 

57 **Example (CLI):** 

58 ```bash 

59 curl -X POST http://localhost:8000/v1/auth/device 

60 ``` 

61 

62 **Response:** 

63 ```json 

64 { 

65 "device_code": "abc123...", 

66 "user_code": "ABC-DEF", 

67 "verification_url": "https://www.alprina.com/authorize", 

68 "expires_in": 900, 

69 "interval": 5 

70 } 

71 ``` 

72 

73 **CLI Flow:** 

74 1. GET device_code and user_code 

75 2. Open browser to verification_url 

76 3. Poll /auth/device/token every 5 seconds 

77 4. Receive API key when user authorizes 

78 """ 

79 if not neon_service.is_enabled(): 

80 raise HTTPException( 

81 status_code=503, 

82 detail="Database not configured" 

83 ) 

84 

85 try: 

86 auth = await neon_service.create_device_authorization() 

87 

88 return DeviceAuthResponse( 

89 device_code=auth["device_code"], 

90 user_code=auth["user_code"], 

91 verification_url="https://www.alprina.com/authorize", 

92 expires_in=900, 

93 interval=5 

94 ) 

95 

96 except Exception as e: 

97 raise HTTPException( 

98 status_code=500, 

99 detail=f"Failed to create device authorization: {str(e)}" 

100 ) 

101 

102 

103@router.post("/auth/device/token") 

104async def poll_device_authorization(request: DeviceTokenRequest): 

105 """ 

106 Step 2: CLI polls for authorization status. 

107 

108 CLI calls this endpoint every 5 seconds with device_code. 

109 Returns 400 (pending) until user authorizes. 

110 Returns 200 with API key when authorized. 

111 

112 **Example (CLI):** 

113 ```bash 

114 curl -X POST http://localhost:8000/v1/auth/device/token \\ 

115 -H "Content-Type: application/json" \\ 

116 -d '{"device_code": "abc123..."}' 

117 ``` 

118 

119 **Response (pending):** 

120 ```json 

121 { 

122 "error": "authorization_pending", 

123 "message": "User hasn't authorized yet" 

124 } 

125 ``` 

126 

127 **Response (authorized):** 

128 ```json 

129 { 

130 "api_key": "alprina_sk_live_...", 

131 "user": {...} 

132 } 

133 ``` 

134 """ 

135 if not neon_service.is_enabled(): 

136 raise HTTPException( 

137 status_code=503, 

138 detail="Database not configured" 

139 ) 

140 

141 try: 

142 auth = await neon_service.check_device_authorization(request.device_code) 

143 

144 if not auth: 

145 raise HTTPException( 

146 status_code=404, 

147 detail="Invalid device code" 

148 ) 

149 

150 if auth["status"] == "expired": 

151 raise HTTPException( 

152 status_code=400, 

153 detail={ 

154 "error": "expired_token", 

155 "message": "Device code has expired. Please request a new one." 

156 } 

157 ) 

158 

159 if auth["status"] == "pending": 

160 raise HTTPException( 

161 status_code=400, 

162 detail={ 

163 "error": "authorization_pending", 

164 "message": "User hasn't authorized yet. Keep polling." 

165 } 

166 ) 

167 

168 if auth["status"] == "authorized": 

169 # Get user and API key 

170 user_id = auth["user_id"] 

171 user = await neon_service.get_user_by_id(user_id) 

172 

173 if not user: 

174 raise HTTPException(404, "User not found") 

175 

176 # Always create a new API key for CLI (we can't retrieve existing keys) 

177 api_key = neon_service.generate_api_key() 

178 await neon_service.create_api_key( 

179 user_id=user_id, 

180 api_key=api_key, 

181 name="CLI (Device Authorization)", 

182 expires_at=datetime.now() + timedelta(days=365) # CLI keys last 1 year 

183 ) 

184 

185 # Clean up device code (use Neon's pool, not Supabase client) 

186 pool = await neon_service.get_pool() 

187 async with pool.acquire() as conn: 

188 await conn.execute( 

189 "DELETE FROM device_codes WHERE device_code = $1", 

190 request.device_code 

191 ) 

192 

193 return { 

194 "api_key": api_key, 

195 "user": { 

196 "id": str(user["id"]), 

197 "email": user["email"], 

198 "full_name": user["full_name"], 

199 "tier": user["tier"] 

200 } 

201 } 

202 

203 except HTTPException: 

204 raise 

205 except Exception as e: 

206 raise HTTPException( 

207 status_code=500, 

208 detail=f"Failed to check authorization: {str(e)}" 

209 ) 

210 

211 

212@router.post("/auth/device/authorize") 

213async def authorize_device(request: AuthorizeDeviceRequest): 

214 """ 

215 Step 3: User authorizes device from browser. 

216 

217 User visits /activate page, logs in via Stack Auth, enters user_code. 

218 This endpoint marks the device as authorized. 

219 

220 **Two modes:** 

221 1. With Stack Auth (recommended): Pass stack_user_id, email, full_name 

222 2. With API key: Use Authorization header (legacy) 

223 

224 **Example (Stack Auth from web):** 

225 ```javascript 

226 fetch('/v1/auth/device/authorize', { 

227 method: 'POST', 

228 headers: {'Content-Type': 'application/json'}, 

229 body: JSON.stringify({ 

230 user_code: 'ABCD-1234', 

231 stack_user_id: 'user_...', 

232 email: 'user@example.com', 

233 full_name: 'John Doe' 

234 }) 

235 }) 

236 ``` 

237 

238 **Response:** 

239 ```json 

240 { 

241 "message": "Device authorized successfully", 

242 "user_code": "ABCD-1234" 

243 } 

244 ``` 

245 """ 

246 if not neon_service.is_enabled(): 

247 raise HTTPException( 

248 status_code=503, 

249 detail="Database not configured" 

250 ) 

251 

252 try: 

253 # Get or create user from Stack Auth 

254 if request.stack_user_id: 

255 # Stack Auth flow 

256 async with neon_service.pool.acquire() as conn: 

257 # Check if user exists 

258 user = await conn.fetchrow( 

259 "SELECT id FROM users WHERE stack_user_id = $1", 

260 request.stack_user_id 

261 ) 

262 

263 if not user: 

264 # Create new user 

265 user = await conn.fetchrow( 

266 """ 

267 INSERT INTO users (stack_user_id, email, full_name, tier) 

268 VALUES ($1, $2, $3, 'none') 

269 RETURNING id 

270 """, 

271 request.stack_user_id, 

272 request.email, 

273 request.full_name 

274 ) 

275 

276 user_id = str(user['id']) 

277 else: 

278 raise HTTPException( 

279 status_code=400, 

280 detail="stack_user_id is required" 

281 ) 

282 

283 # Authorize the device 

284 success = await neon_service.authorize_device( 

285 user_code=request.user_code.upper(), 

286 user_id=user_id 

287 ) 

288 

289 if not success: 

290 raise HTTPException( 

291 status_code=404, 

292 detail="Invalid user code or authorization expired" 

293 ) 

294 

295 return { 

296 "message": "Device authorized successfully", 

297 "user_code": request.user_code 

298 } 

299 

300 except HTTPException: 

301 raise 

302 except Exception as e: 

303 raise HTTPException( 

304 status_code=500, 

305 detail=f"Failed to authorize device: {str(e)}" 

306 ) 

307 

308 

309@router.post("/auth/dashboard-code") 

310async def generate_dashboard_code(authorization: str = Header(...)): 

311 """ 

312 Generate a 6-digit code from the dashboard that users can enter in CLI. 

313 

314 This is the reverse flow: Dashboard → CLI (instead of CLI → Dashboard). 

315 Much simpler and more reliable than URL parameters. 

316 

317 **Example (from dashboard):** 

318 ```javascript 

319 fetch('/v1/auth/dashboard-code', { 

320 method: 'POST', 

321 headers: { 

322 'Authorization': 'Bearer user_...' 

323 } 

324 }) 

325 ``` 

326 

327 **Response:** 

328 ```json 

329 { 

330 "cli_code": "ABC123", 

331 "expires_in": 900, 

332 "message": "Enter this code in your CLI: alprina auth login --code ABC123" 

333 } 

334 ``` 

335 """ 

336 if not neon_service.is_enabled(): 

337 raise HTTPException( 

338 status_code=503, 

339 detail="Database not configured" 

340 ) 

341 

342 try: 

343 # Extract Stack user ID from Authorization header 

344 stack_user_id = authorization.replace("Bearer ", "").strip() 

345 

346 if not stack_user_id: 

347 raise HTTPException( 

348 status_code=401, 

349 detail="Authorization header required" 

350 ) 

351 

352 # Get or create user from Stack Auth ID 

353 async with neon_service.pool.acquire() as conn: 

354 user = await conn.fetchrow( 

355 "SELECT id FROM users WHERE stack_user_id = $1", 

356 stack_user_id 

357 ) 

358 

359 if not user: 

360 # Create new user 

361 user = await conn.fetchrow( 

362 """ 

363 INSERT INTO users (stack_user_id, email, full_name, tier) 

364 VALUES ($1, $2, $3, 'none') 

365 RETURNING id 

366 """, 

367 stack_user_id, 

368 "dashboard@user.com", # Placeholder, will be updated 

369 "Dashboard User" # Placeholder, will be updated 

370 ) 

371 

372 user_id = str(user['id']) 

373 

374 # Generate a 6-digit alphanumeric code 

375 import secrets 

376 import string 

377 cli_code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(6)) 

378 

379 # Store the code in device_codes table with special type 

380 async with neon_service.pool.acquire() as conn: 

381 await conn.execute( 

382 """ 

383 INSERT INTO device_codes (device_code, user_code, user_id, status, expires_at, created_at) 

384 VALUES ($1, $2, $3, 'pending', NOW() + INTERVAL '15 minutes', NOW()) 

385 """, 

386 f"dashboard_{cli_code}", # Prefix to identify dashboard codes 

387 cli_code, 

388 user_id 

389 ) 

390 

391 return { 

392 "cli_code": cli_code, 

393 "expires_in": 900, # 15 minutes 

394 "message": f"Enter this code in your CLI: alprina auth login --code {cli_code}" 

395 } 

396 

397 except HTTPException: 

398 raise 

399 except Exception as e: 

400 raise HTTPException( 

401 status_code=500, 

402 detail=f"Failed to generate dashboard code: {str(e)}" 

403 ) 

404 

405 

406@router.post("/auth/cli-verify") 

407async def verify_cli_code(request: CLICodeRequest): 

408 """ 

409 Verify a CLI code entered by the user (reverse flow). 

410 

411 User gets code from dashboard, enters it in CLI. 

412 This endpoint verifies the code and returns an API key. 

413 

414 **Example (CLI):** 

415 ```bash 

416 curl -X POST /v1/auth/cli-verify \\ 

417 -H "Content-Type: application/json" \\ 

418 -d '{"cli_code": "ABC123"}' 

419 ``` 

420 

421 **Response:** 

422 ```json 

423 { 

424 "api_key": "alprina_sk_live_...", 

425 "user": { 

426 "id": "123", 

427 "email": "user@example.com" 

428 } 

429 } 

430 ``` 

431 """ 

432 if not neon_service.is_enabled(): 

433 raise HTTPException( 

434 status_code=503, 

435 detail="Database not configured" 

436 ) 

437 

438 try: 

439 # Find the dashboard code 

440 async with neon_service.pool.acquire() as conn: 

441 code_record = await conn.fetchrow( 

442 """ 

443 SELECT device_code, user_id, status, expires_at 

444 FROM device_codes 

445 WHERE user_code = $1 

446 AND device_code LIKE 'dashboard_%' 

447 AND expires_at > NOW() 

448 """, 

449 request.cli_code.upper() 

450 ) 

451 

452 if not code_record: 

453 raise HTTPException( 

454 status_code=404, 

455 detail="Invalid or expired CLI code" 

456 ) 

457 

458 if code_record['status'] == 'used': 

459 raise HTTPException( 

460 status_code=400, 

461 detail="CLI code has already been used" 

462 ) 

463 

464 user_id = code_record['user_id'] 

465 

466 # Mark code as used 

467 await conn.execute( 

468 "UPDATE device_codes SET status = 'used' WHERE user_code = $1", 

469 request.cli_code.upper() 

470 ) 

471 

472 # Get user details 

473 user = await conn.fetchrow( 

474 "SELECT id, email, full_name, tier FROM users WHERE id = $1", 

475 user_id 

476 ) 

477 

478 if not user: 

479 raise HTTPException(404, "User not found") 

480 

481 # Generate API key 

482 api_key = neon_service.generate_api_key() 

483 await neon_service.create_api_key( 

484 user_id=str(user_id), 

485 api_key=api_key, 

486 name="CLI (Dashboard Code)", 

487 expires_at=datetime.now() + timedelta(days=365) 

488 ) 

489 

490 return { 

491 "api_key": api_key, 

492 "user": { 

493 "id": str(user["id"]), 

494 "email": user["email"], 

495 "full_name": user["full_name"], 

496 "tier": user["tier"] 

497 } 

498 } 

499 

500 except HTTPException: 

501 raise 

502 except Exception as e: 

503 raise HTTPException( 

504 status_code=500, 

505 detail=f"Failed to verify CLI code: {str(e)}" 

506 ) 

507 

508 

509@router.get("/auth/devices", response_model=List[DeviceInfo]) 

510async def list_user_devices(authorization: str = Header(...)): 

511 """ 

512 List connected CLI devices for the current user. 

513 

514 Used by the settings dashboard to show authorized devices. 

515 Expects Stack Auth user ID in Authorization header. 

516 

517 **Example:** 

518 ```javascript 

519 fetch('/v1/auth/devices', { 

520 headers: { 

521 'Authorization': 'Bearer user_...' 

522 } 

523 }) 

524 ``` 

525 

526 **Response:** 

527 ```json 

528 [ 

529 { 

530 "id": "key_123", 

531 "name": "CLI (Device Authorization)", 

532 "created_at": "2024-01-01T00:00:00Z", 

533 "last_used": "2024-01-01T12:00:00Z", 

534 "status": "active" 

535 } 

536 ] 

537 ``` 

538 """ 

539 if not neon_service.is_enabled(): 

540 raise HTTPException( 

541 status_code=503, 

542 detail="Database not configured" 

543 ) 

544 

545 try: 

546 # Extract Stack user ID from Authorization header 

547 stack_user_id = authorization.replace("Bearer ", "").strip() 

548 

549 if not stack_user_id: 

550 raise HTTPException( 

551 status_code=401, 

552 detail="Authorization header required" 

553 ) 

554 

555 # Get user from Stack Auth ID 

556 async with neon_service.pool.acquire() as conn: 

557 user = await conn.fetchrow( 

558 "SELECT id FROM users WHERE stack_user_id = $1", 

559 stack_user_id 

560 ) 

561 

562 if not user: 

563 raise HTTPException( 

564 status_code=404, 

565 detail="User not found" 

566 ) 

567 

568 user_id = user['id'] 

569 

570 # Get API keys (devices) for this user 

571 devices = await conn.fetch( 

572 """ 

573 SELECT id, name, created_at, last_used_at, is_active 

574 FROM api_keys 

575 WHERE user_id = $1 

576 ORDER BY created_at DESC 

577 """, 

578 user_id 

579 ) 

580 

581 return [ 

582 DeviceInfo( 

583 id=str(device['id']), 

584 name=device['name'], 

585 created_at=device['created_at'], 

586 last_used=device['last_used_at'], 

587 status="active" if device['is_active'] else "revoked" 

588 ) 

589 for device in devices 

590 ] 

591 

592 except HTTPException: 

593 raise 

594 except Exception as e: 

595 raise HTTPException( 

596 status_code=500, 

597 detail=f"Failed to list devices: {str(e)}" 

598 )