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
« 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"""
6from fastapi import APIRouter, HTTPException, Depends, Header
7from pydantic import BaseModel
8from typing import Dict, Any, List, Optional
9from datetime import datetime, timedelta
11from ..services.neon_service import neon_service
12from ..middleware.auth import get_current_user
14router = APIRouter()
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
25class DeviceTokenRequest(BaseModel):
26 device_code: str
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
36class CLICodeRequest(BaseModel):
37 cli_code: str
40class DeviceInfo(BaseModel):
41 id: str
42 name: str
43 created_at: datetime
44 last_used: Optional[datetime]
45 status: str # active, revoked
48@router.post("/auth/device", response_model=DeviceAuthResponse)
49async def request_device_authorization():
50 """
51 Step 1: CLI requests device authorization.
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.
57 **Example (CLI):**
58 ```bash
59 curl -X POST http://localhost:8000/v1/auth/device
60 ```
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 ```
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 )
85 try:
86 auth = await neon_service.create_device_authorization()
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 )
96 except Exception as e:
97 raise HTTPException(
98 status_code=500,
99 detail=f"Failed to create device authorization: {str(e)}"
100 )
103@router.post("/auth/device/token")
104async def poll_device_authorization(request: DeviceTokenRequest):
105 """
106 Step 2: CLI polls for authorization status.
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.
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 ```
119 **Response (pending):**
120 ```json
121 {
122 "error": "authorization_pending",
123 "message": "User hasn't authorized yet"
124 }
125 ```
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 )
141 try:
142 auth = await neon_service.check_device_authorization(request.device_code)
144 if not auth:
145 raise HTTPException(
146 status_code=404,
147 detail="Invalid device code"
148 )
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 )
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 )
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)
173 if not user:
174 raise HTTPException(404, "User not found")
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 )
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 )
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 }
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 )
212@router.post("/auth/device/authorize")
213async def authorize_device(request: AuthorizeDeviceRequest):
214 """
215 Step 3: User authorizes device from browser.
217 User visits /activate page, logs in via Stack Auth, enters user_code.
218 This endpoint marks the device as authorized.
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)
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 ```
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 )
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 )
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 )
276 user_id = str(user['id'])
277 else:
278 raise HTTPException(
279 status_code=400,
280 detail="stack_user_id is required"
281 )
283 # Authorize the device
284 success = await neon_service.authorize_device(
285 user_code=request.user_code.upper(),
286 user_id=user_id
287 )
289 if not success:
290 raise HTTPException(
291 status_code=404,
292 detail="Invalid user code or authorization expired"
293 )
295 return {
296 "message": "Device authorized successfully",
297 "user_code": request.user_code
298 }
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 )
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.
314 This is the reverse flow: Dashboard → CLI (instead of CLI → Dashboard).
315 Much simpler and more reliable than URL parameters.
317 **Example (from dashboard):**
318 ```javascript
319 fetch('/v1/auth/dashboard-code', {
320 method: 'POST',
321 headers: {
322 'Authorization': 'Bearer user_...'
323 }
324 })
325 ```
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 )
342 try:
343 # Extract Stack user ID from Authorization header
344 stack_user_id = authorization.replace("Bearer ", "").strip()
346 if not stack_user_id:
347 raise HTTPException(
348 status_code=401,
349 detail="Authorization header required"
350 )
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 )
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 )
372 user_id = str(user['id'])
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))
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 )
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 }
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 )
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).
411 User gets code from dashboard, enters it in CLI.
412 This endpoint verifies the code and returns an API key.
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 ```
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 )
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 )
452 if not code_record:
453 raise HTTPException(
454 status_code=404,
455 detail="Invalid or expired CLI code"
456 )
458 if code_record['status'] == 'used':
459 raise HTTPException(
460 status_code=400,
461 detail="CLI code has already been used"
462 )
464 user_id = code_record['user_id']
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 )
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 )
478 if not user:
479 raise HTTPException(404, "User not found")
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 )
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 }
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 )
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.
514 Used by the settings dashboard to show authorized devices.
515 Expects Stack Auth user ID in Authorization header.
517 **Example:**
518 ```javascript
519 fetch('/v1/auth/devices', {
520 headers: {
521 'Authorization': 'Bearer user_...'
522 }
523 })
524 ```
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 )
545 try:
546 # Extract Stack user ID from Authorization header
547 stack_user_id = authorization.replace("Bearer ", "").strip()
549 if not stack_user_id:
550 raise HTTPException(
551 status_code=401,
552 detail="Authorization header required"
553 )
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 )
562 if not user:
563 raise HTTPException(
564 status_code=404,
565 detail="User not found"
566 )
568 user_id = user['id']
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 )
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 ]
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 )