# Device Code Authentication (RFC 8628)

> How `llmhosts up` authenticates with LLMHosts.com without requiring the user to paste tokens into a terminal.

This document specifies the complete Device Authorization Grant flow (RFC 8628) as implemented in LLMHosts. This flow allows a CLI tool running on a headless or limited-input device to authenticate by having the user complete authorization in a web browser.

---

## Table of Contents

1. [Overview](#overview)
2. [Why Device Code Flow](#why-device-code-flow)
3. [Flow Diagrams](#flow-diagrams)
4. [Endpoint Specifications](#endpoint-specifications)
5. [Token Lifecycle](#token-lifecycle)
6. [Security Considerations](#security-considerations)
7. [Database Schema](#database-schema)
8. [Implementation Details](#implementation-details)
9. [Error Handling](#error-handling)
10. [Testing](#testing)

---

## Overview

The Device Code Flow solves a fundamental UX problem: how does a CLI tool running on a machine with a GPU authenticate with a web service, without requiring the user to copy-paste tokens from a browser into a terminal?

**The experience:**

```
$ llmhosts up

Starting Ollama... done (3 models available)
Starting proxy on :4000... done
Opening tunnel... done (Cloudflare Quick Tunnel)

To connect this device to your LLMHosts account:

  1. Open:  https://llmhosts.com/activate
  2. Enter: ABCD-1234

Waiting for approval... (expires in 15 minutes)
```

The user opens their browser, logs in (if not already), enters the code, and clicks "Approve". The CLI detects the approval within seconds and continues.

```
Approved! Device registered as "home-desktop"

Your GPU is live!
  Endpoint: https://llmhosts.com/v1/chat/completions
  API Key:  lh_sk_Ab...Xy
  Models:   llama3.1:70b, codestral:latest, mistral:7b
```

---

## Why Device Code Flow

We evaluated four authentication approaches:

| Approach | Pros | Cons | Verdict |
|---|---|---|---|
| **Paste API key** | Simple | Error-prone, exposes key in terminal history | Rejected |
| **OAuth PKCE (browser redirect)** | Standard | Requires localhost callback, fails on headless/SSH | Rejected |
| **Magic link via email** | No browser needed on device | Slow, requires email setup, bad UX | Rejected |
| **Device Code (RFC 8628)** | Works on any device, no clipboard needed, user approves in browser | Requires polling | **Selected** |

Device Code Flow is the same mechanism used by:
- GitHub CLI (`gh auth login`)
- Microsoft Azure CLI
- Apple TV app activation
- Smart TV streaming apps

It works on headless servers, SSH sessions, WSL, Docker containers, and any environment where a full browser redirect is impractical.

---

## Flow Diagrams

### Complete Flow (Three Participants)

```
CLI (llmhosts up)              LLMHosts.com Server              User's Browser
     |                               |                               |
     |  POST /api/auth/device-code   |                               |
     |  { client_id: "llmhosts-cli" } |                               |
     |------------------------------>|                               |
     |                               |  Generate:                    |
     |                               |    device_code = random(48)   |
     |                               |    user_code = XXXX-XXXX      |
     |                               |    expires = now + 15min      |
     |                               |  Store in DeviceCode table    |
     |  {                            |                               |
     |    device_code: "GmRh...xyz", |                               |
     |    user_code: "ABCD-1234",    |                               |
     |    verification_uri: "https://llmhosts.com/activate",         |
     |    expires_in: 900,           |                               |
     |    interval: 5                |                               |
     |  }                            |                               |
     |<------------------------------|                               |
     |                               |                               |
     |  Print to terminal:           |                               |
     |  "Visit .../activate"         |                               |
     |  "Enter code: ABCD-1234"      |                               |
     |                               |                               |
     |  Attempt to open browser -----|------------------------------>|
     |  (best effort, non-blocking)  |                               |
     |                               |                               |
     |                               |          User opens browser   |
     |                               |          Navigates to /activate
     |                               |                               |
     |                               |  GET /activate                |
     |                               |<------------------------------|
     |                               |  Serve activation page        |
     |                               |------------------------------>|
     |                               |                               |
     |                               |          User enters ABCD-1234|
     |                               |          Clicks "Approve"     |
     |                               |                               |
     |                               |  POST /api/auth/device-code/approve
     |                               |  { user_code: "ABCD-1234" }  |
     |                               |<------------------------------|
     |                               |                               |
     |                               |  Validate:                    |
     |                               |    - Code exists and not expired
     |                               |    - User is authenticated    |
     |                               |  Update DeviceCode:           |
     |                               |    - userId = session.user.id |
     |                               |    - approved = true          |
     |                               |  Generate API key:            |
     |                               |    - lh_sk_<random>           |
     |                               |    - Store in ApiKey table    |
     |                               |    - Link to DeviceCode       |
     |                               |                               |
     |                               |  { success: true }            |
     |                               |------------------------------>|
     |                               |                               |
     |  Poll: POST /api/auth/device-code/poll                       |
     |  { device_code: "GmRh...xyz" }|                               |
     |------------------------------>|                               |
     |                               |  Check DeviceCode:            |
     |                               |    approved = true            |
     |                               |  Generate access_token (JWT)  |
     |                               |  Return API key               |
     |  {                            |                               |
     |    status: "approved",        |                               |
     |    access_token: "eyJ...",    |                               |
     |    api_key: "lh_sk_Ab...Xy"   |                               |
     |  }                            |                               |
     |<------------------------------|                               |
     |                               |                               |
     |  Store tokens locally         |                               |
     |  Proceed to device            |                               |
     |  registration                 |                               |
     v                               v                               v
```

### CLI-Side State Machine

```
                    +-------------+
                    |    INIT     |
                    +------+------+
                           |
                    POST /device-code
                           |
                    +------v------+
                    | CODE_ISSUED |
                    +------+------+
                           |
                    Print code to terminal
                    Start polling (every 5s)
                           |
              +------------+------------+
              |            |            |
        +-----v-----+ +---v---+ +-----v-----+
        |  APPROVED  | |PENDING| |  EXPIRED  |
        +-----+-----+ +---+---+ +-----+-----+
              |            |            |
        Store tokens  Continue    Print error
        Register       polling    Offer restart
        device                         |
              |            |      +----v----+
              v            |      |  FAILED |
        +----------+       |      +---------+
        | COMPLETE |       |
        +----------+  (max 180 polls =
                        15 min @ 5s)
```

### Server-Side State Machine

```
DeviceCode record lifecycle:

  CREATED (device_code, user_code, expires, approved=false, userId=null)
     |
     +--> Poll requests return { status: "pending" }
     |
     +--> User approves in browser:
     |       UPDATE SET approved=true, userId=<user_id>, apiKeyId=<new_key_id>
     |
     +--> APPROVED
     |       Next poll returns { status: "approved", access_token, api_key }
     |
     +--> CONSUMED (access_token issued)
     |       Subsequent polls return { status: "already_consumed" }
     |
     +--> EXPIRED (expires < now())
             Poll returns { status: "expired" }
             Cleaned up by background job after 24h
```

---

## Endpoint Specifications

### `POST /api/auth/device-code` -- Initiate Device Code Flow

Creates a new device code pair and returns it to the CLI.

**Request:**

```
POST /api/auth/device-code
Content-Type: application/json

{
  "client_id": "llmhosts-cli",
  "scope": "device:register device:heartbeat"
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `client_id` | `string` | Yes | Must be `"llmhosts-cli"` |
| `scope` | `string` | No | Space-separated scopes (default: `device:register device:heartbeat`) |

**Response (200 OK):**

```json
{
  "device_code": "GmRhTjmW4RE9c-O_BDy5AKJw4E0fH8q3vNkZLe_j7xs",
  "user_code": "ABCD-1234",
  "verification_uri": "https://llmhosts.com/activate",
  "verification_uri_complete": "https://llmhosts.com/activate?code=ABCD-1234",
  "expires_in": 900,
  "interval": 5
}
```

| Field | Type | Description |
|---|---|---|
| `device_code` | `string` | 48-byte URL-safe random token. Used by CLI for polling. Never shown to user. |
| `user_code` | `string` | 8-character human-readable code (`XXXX-XXXX`). Shown to user. |
| `verification_uri` | `string` | URL where user enters the code |
| `verification_uri_complete` | `string` | URL with code pre-filled (for QR codes, deep links) |
| `expires_in` | `integer` | Seconds until code expires (900 = 15 minutes) |
| `interval` | `integer` | Minimum seconds between poll requests |

**User Code Format:** `[A-Z0-9]{4}-[A-Z0-9]{4}` (e.g., `ABCD-1234`, `K7MR-9XPN`)
- 4 characters, hyphen, 4 characters
- Uppercase letters and digits only
- Excludes ambiguous characters: `0/O`, `1/I/L` (to avoid confusion)
- Total entropy: ~20 bits (sufficient for 15-minute window)

**Device Code Format:** 48-byte URL-safe base64 (`secrets.token_urlsafe(48)`)
- Total entropy: ~256 bits
- Never shown to user, only transmitted over HTTPS

**Rate Limiting:**
- 10 device code requests per IP per hour
- 3 active (non-expired) device codes per IP at any time

### `POST /api/auth/device-code/poll` -- Poll for Approval

The CLI polls this endpoint at the specified interval to check if the user has approved.

**Request:**

```
POST /api/auth/device-code/poll
Content-Type: application/json

{
  "device_code": "GmRhTjmW4RE9c-O_BDy5AKJw4E0fH8q3vNkZLe_j7xs"
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `device_code` | `string` | Yes | The device code from the initiation response |

**Response Variants:**

**Pending (200 OK):**
```json
{ "status": "pending" }
```

**Approved (200 OK):**
```json
{
  "status": "approved",
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 86400,
  "api_key": "lh_sk_AbCdEfGhIjKlMnOpQrStUvWxYz012345",
  "api_key_prefix": "lh_sk_Ab"
}
```

| Field | Type | Description |
|---|---|---|
| `access_token` | `string` | JWT for device registration and heartbeat API calls |
| `token_type` | `string` | Always `"Bearer"` |
| `expires_in` | `integer` | Access token lifetime in seconds (86400 = 24 hours) |
| `api_key` | `string` | User's API key for the relay system. Shown once. |
| `api_key_prefix` | `string` | First 8 chars of the key for display purposes |

**Expired (200 OK):**
```json
{ "status": "expired" }
```

**Denied (200 OK):**
```json
{ "status": "denied" }
```

**Slow Down (429 Too Many Requests):**
```json
{
  "error": "slow_down",
  "interval": 10
}
```

The `interval` field tells the CLI to increase its polling interval to the specified value. The CLI must honor this.

**Already Consumed (200 OK):**
```json
{ "status": "already_consumed" }
```

Returned if the access token has already been issued for this device code. Prevents replay.

### `POST /api/auth/device-code/approve` -- User Approves Code (Browser)

Called from the browser when the user enters the code and clicks "Approve".

**Request:**

```
POST /api/auth/device-code/approve
Content-Type: application/json
Cookie: <session cookie from NextAuth>

{
  "user_code": "ABCD-1234"
}
```

**Preconditions:**
- User must be authenticated (NextAuth session)
- User code must exist, not expired, and not already approved

**Response (200 OK):**
```json
{
  "success": true,
  "device_name": null,
  "message": "Device authorized. You can close this tab."
}
```

**Error Responses:**

| Status | Body | Cause |
|---|---|---|
| `400` | `{"error":"invalid_code","message":"Code not found or already used"}` | Code doesn't exist or already approved |
| `400` | `{"error":"expired_code","message":"This code has expired. Request a new one."}` | Code past expiration |
| `401` | `{"error":"unauthenticated"}` | No active browser session |
| `429` | `{"error":"too_many_attempts"}` | Brute-force protection (5 attempts per session per 5 min) |

### `GET /activate` -- Activation Page (Browser)

The web page where users enter their device code.

**Query Parameters:**

| Parameter | Type | Description |
|---|---|---|
| `code` | `string` | Optional. Pre-fills the code input field. |

**Page Behavior:**
1. If user is not logged in, redirect to `/login?callbackUrl=/activate`
2. Show a centered card with a code input field (4 chars + hyphen + 4 chars)
3. Auto-format: uppercase, auto-advance after 4 chars, auto-insert hyphen
4. "Approve" button submits `POST /api/auth/device-code/approve`
5. On success: show confirmation with device info
6. If `?code=ABCD-1234` is present, auto-fill and optionally auto-submit after 2 seconds

---

## Token Lifecycle

### Device Code Lifecycle

```
Created ──(15 min)──> Expired ──(24h)──> Deleted (background cleanup)
   |
   └──> Approved ──(token issued)──> Consumed ──(24h)──> Deleted
```

| State | `approved` | `userId` | `apiKeyId` | Duration |
|---|---|---|---|---|
| Created | `false` | `null` | `null` | 0 |
| Approved | `true` | Set | Set | < 15 min |
| Consumed | `true` | Set | Set | After first poll |
| Expired | `false` | `null` | `null` | After 15 min |
| Deleted | N/A | N/A | N/A | After 24h |

### Access Token (JWT) Lifecycle

```
Issued on approval
  |
  ├── Valid for 24 hours (default)
  |   Used for: POST /api/devices/register
  |             POST /api/devices/heartbeat
  |             DELETE /api/devices/:id
  |             GET /api/devices
  |
  ├── Refresh: POST /api/auth/token/refresh (before expiry)
  |   Returns new access_token with fresh 24h window
  |
  └── Expired: 401 on next API call
      CLI must re-authenticate via device code flow
```

**JWT Claims:**

```json
{
  "sub": "cluser123...",
  "iss": "https://llmhosts.com",
  "aud": "llmhosts-cli",
  "iat": 1740268800,
  "exp": 1740355200,
  "scope": "device:register device:heartbeat",
  "plan": "pro"
}
```

| Claim | Description |
|---|---|
| `sub` | User ID (cuid from Prisma) |
| `iss` | Issuer (always `https://llmhosts.com`) |
| `aud` | Audience (always `llmhosts-cli`) |
| `iat` | Issued at (Unix timestamp) |
| `exp` | Expiration (Unix timestamp, 24h from `iat`) |
| `scope` | Granted scopes |
| `plan` | User's subscription plan (for device limit enforcement) |

### API Key Lifecycle

```
Generated on device code approval
  |
  ├── Active: used for relay requests
  |   Authorization: Bearer lh_sk_AbCd...
  |   lastUsed updated on each request
  |
  ├── Revoked: user revokes in dashboard
  |   revokedAt set, key immediately invalid
  |
  └── Expired: expiresAt reached (if set)
      Key no longer valid
```

**Key Storage on CLI side:** `~/.llmhosts/auth.json`

```json
{
  "access_token": "eyJ...",
  "access_token_expires_at": "2026-02-23T10:00:00Z",
  "api_key": "lh_sk_AbCdEfGhIjKlMnOpQrStUvWxYz012345",
  "api_key_prefix": "lh_sk_Ab",
  "user_id": "cluser123...",
  "authenticated_at": "2026-02-22T10:00:00Z"
}
```

File permissions: `0o600` (owner read/write only).

---

## Security Considerations

### Brute-Force Protection on User Code

The user code has ~20 bits of entropy (8 alphanumeric characters excluding ambiguous ones). In a 15-minute window, an attacker could theoretically try all combinations.

**Mitigations:**
1. **Rate limiting:** 5 attempts per session per 5 minutes on `/api/auth/device-code/approve`
2. **IP-based rate limiting:** 20 attempts per IP per 15 minutes
3. **Exponential backoff:** After 3 failures, each subsequent attempt requires a 2^n second delay
4. **Account lockout:** After 10 consecutive failures, the user code is invalidated
5. **Short window:** Codes expire after 15 minutes

**Attack calculation:**
- 36^4 * 36^4 (minus excluded characters) = ~1.68 billion combinations
- At 5 attempts per 5 minutes = 60 attempts per hour
- Time to brute-force: ~3.2 million hours (impossible in 15-minute window)

### PKCE-Like Protection for Device Code

While RFC 8628 doesn't require PKCE, we add a layer of protection:

1. The `device_code` is a 48-byte cryptographically random token (256 bits of entropy)
2. It is only transmitted over HTTPS
3. It is never shown to the user or displayed in a browser
4. It is bound to a single approval event
5. After the access token is issued, the device code is consumed and cannot be reused

### Token Storage Security

```
~/.llmhosts/
  auth.json          (0o600) -- access token + API key
  tunnel_key.json    (0o600) -- tunnel-level auth key
  config.toml        (0o644) -- no secrets
  devices.json       (0o644) -- no secrets
```

- All credential files are created with `0o600` permissions (owner-only read/write)
- Tokens are stored as plain JSON (not encrypted at rest)
- On compromise: revoke API key in dashboard, re-authenticate

**Future enhancement:** OS keychain integration (macOS Keychain, GNOME Keyring, Windows Credential Vault) for credential storage.

### Rate Limiting on Poll Endpoint

| Scenario | Limit | Response |
|---|---|---|
| Normal polling | 1 request per `interval` seconds (default 5s) | 200 OK |
| Polling too fast | < `interval` seconds between requests | 429 with increased `interval` |
| Excessive polling | > 200 requests per device_code | Device code invalidated |

The CLI implementation MUST honor the `interval` field and the `slow_down` response. The server tracks the timestamp of each poll and rejects requests that arrive before the interval has elapsed.

### Cross-Site Request Forgery (CSRF) Protection

The `/api/auth/device-code/approve` endpoint is called from the browser and is protected by:
1. NextAuth CSRF token (included in the session cookie)
2. `SameSite=Lax` cookie policy
3. Origin header validation
4. The user must be authenticated with an active session

---

## Database Schema

### DeviceCode Table (Prisma)

```prisma
model DeviceCode {
  id               String   @id @default(cuid())
  deviceCode       String   @unique  // 48-byte URL-safe random
  userCode         String   @unique  // XXXX-XXXX format
  userId           String?           // null until approved
  user             User?    @relation(fields: [userId], references: [id], onDelete: Cascade)
  approved         Boolean  @default(false)
  apiKeyId         String?           // API key generated on approval
  expires          DateTime          // 15 minutes from creation
  createdAt        DateTime @default(now())

  @@index([deviceCode])
  @@index([userCode])
}
```

### Related Tables

```prisma
model ApiKey {
  id        String   @id @default(cuid())
  userId    String
  name      String   // "Device: home-desktop" (auto-generated on device auth)
  key       String   @unique  // lh_sk_<random>
  prefix    String   // lh_sk_Ab (first 8 chars)
  lastUsed  DateTime?
  expiresAt DateTime?
  createdAt DateTime @default(now())
  revokedAt DateTime?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```

---

## Implementation Details

### CLI-Side Implementation (`llmhosts/auth/device_code.py`)

```python
class DeviceCodeAuth:
    """RFC 8628 Device Authorization Grant client implementation."""

    LLMHOSTS_BASE = "https://llmhosts.com"
    CLIENT_ID = "llmhosts-cli"
    MAX_POLLS = 180  # 15 min at 5s interval

    async def authenticate(self) -> AuthResult:
        """Run the full device code flow. Returns tokens on success."""

        # Step 1: Request device code
        resp = await self._request_code()
        device_code = resp["device_code"]
        user_code = resp["user_code"]
        verification_uri = resp["verification_uri"]
        interval = resp["interval"]
        expires_in = resp["expires_in"]

        # Step 2: Display code to user
        self._display_code(user_code, verification_uri)

        # Step 3: Open browser (best effort)
        self._open_browser(resp.get("verification_uri_complete", verification_uri))

        # Step 4: Poll for approval
        result = await self._poll(device_code, interval, expires_in)

        # Step 5: Store credentials
        await self._store_credentials(result)

        return result

    async def _poll(self, device_code: str, interval: int, expires_in: int) -> AuthResult:
        """Poll with exponential backoff on slow_down."""
        polls = 0
        current_interval = interval

        while polls < self.MAX_POLLS:
            await asyncio.sleep(current_interval)
            polls += 1

            resp = await httpx.AsyncClient().post(
                f"{self.LLMHOSTS_BASE}/api/auth/device-code/poll",
                json={"device_code": device_code},
            )

            if resp.status_code == 429:
                # slow_down: increase interval
                data = resp.json()
                current_interval = data.get("interval", current_interval + 5)
                continue

            data = resp.json()
            status = data.get("status")

            if status == "approved":
                return AuthResult(
                    access_token=data["access_token"],
                    api_key=data["api_key"],
                    api_key_prefix=data["api_key_prefix"],
                )
            elif status == "expired":
                raise AuthExpiredError("Device code expired. Please run llmhosts up again.")
            elif status == "denied":
                raise AuthDeniedError("Authorization was denied.")
            # else: pending, continue polling

        raise AuthTimeoutError("Polling timed out after 15 minutes.")
```

### Server-Side Implementation (`frontend/src/app/api/auth/device-code/`)

```
frontend/src/app/api/auth/device-code/
  route.ts          -- POST handler (initiate flow)
  poll/route.ts     -- POST handler (poll for approval)
  approve/route.ts  -- POST handler (user approves in browser)
```

**Code Generation:**

```typescript
// User code: XXXX-XXXX (excluding 0, O, 1, I, L)
const CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";

function generateUserCode(): string {
  const part1 = Array.from({ length: 4 }, () =>
    CHARSET[crypto.getRandomValues(new Uint8Array(1))[0] % CHARSET.length]
  ).join("");
  const part2 = Array.from({ length: 4 }, () =>
    CHARSET[crypto.getRandomValues(new Uint8Array(1))[0] % CHARSET.length]
  ).join("");
  return `${part1}-${part2}`;
}

// Device code: 48-byte URL-safe random
function generateDeviceCode(): string {
  return btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(48))))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");
}
```

### Activation Page (`frontend/src/app/activate/page.tsx`)

The activation page is a focused, single-purpose page:

1. Large, centered card with device-auth branding
2. Code input: two groups of 4 characters, auto-uppercase, auto-advance
3. "Authorize Device" button (disabled until valid code entered)
4. Success state: green check, "Device authorized, you can close this tab"
5. Error states: expired, invalid, already used

**Accessibility:**
- Auto-focus on first input field
- Keyboard navigation (Tab between groups)
- Screen reader announcements for state changes
- Error messages are ARIA live regions

---

## Error Handling

### CLI-Side Error Recovery

| Error | Recovery |
|---|---|
| Network error during code request | Retry 3 times with exponential backoff, then fail with clear message |
| Network error during polling | Continue polling (transient errors expected) |
| Code expired | Print "Code expired. Run `llmhosts up` again to get a new code." |
| Code denied | Print "Authorization denied. Make sure you approve in the browser." |
| Token storage failure | Print warning, continue with in-memory tokens |
| Invalid access token | Re-authenticate from scratch |

### Server-Side Error Recovery

| Error | Recovery |
|---|---|
| Database error during code creation | Return 500 with retry suggestion |
| Race condition (two approvals) | First approval wins, second gets `already_used` |
| JWT signing failure | Return 500, log critical error |
| API key generation collision | Retry with new random bytes (up to 3 times) |

### Background Cleanup

A scheduled job runs every hour to clean up expired device codes:

```sql
DELETE FROM DeviceCode WHERE expires < now() - INTERVAL 24 HOUR;
```

This 24-hour grace period (beyond the 15-minute expiry) ensures that any late poll requests still get a proper "expired" response rather than a confusing "not found".

---

## Testing

### Unit Tests

```python
# tests/test_device_code_auth.py

class TestDeviceCodeFlow:
    """Test the complete device code authentication flow."""

    async def test_initiate_returns_valid_codes(self):
        """POST /api/auth/device-code returns properly formatted codes."""

    async def test_user_code_format(self):
        """User code matches XXXX-XXXX pattern with valid charset."""

    async def test_poll_returns_pending_before_approval(self):
        """Polling before approval returns status: pending."""

    async def test_poll_returns_approved_with_tokens(self):
        """Polling after approval returns access_token and api_key."""

    async def test_poll_returns_expired_after_timeout(self):
        """Polling after expiry returns status: expired."""

    async def test_rate_limiting_on_poll(self):
        """Polling faster than interval returns 429 slow_down."""

    async def test_brute_force_protection_on_approve(self):
        """Multiple wrong codes trigger rate limiting."""

    async def test_device_code_single_use(self):
        """After token issued, subsequent polls return already_consumed."""

    async def test_csrf_protection_on_approve(self):
        """Approve endpoint requires valid CSRF token."""

    async def test_unauthenticated_approve_rejected(self):
        """Approve endpoint requires browser session."""
```

### Integration Tests

```python
class TestDeviceCodeE2E:
    """End-to-end device code flow with real database."""

    async def test_full_flow(self):
        """Initiate -> approve -> poll -> get tokens -> register device."""

    async def test_concurrent_approvals(self):
        """Two users approving different codes simultaneously."""

    async def test_expired_code_cleanup(self):
        """Background job cleans expired codes."""
```

---

## Related Documentation

- [RFC 8628: OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628)
- [README.md](README.md) -- GPU-as-API technical reference
- [Relay Architecture](relay-architecture.md) -- How requests are forwarded after authentication
