# How OAuth Device-Code Flow Streams Through SSE in Pi-Web Auth Routes

> Learn how the Pi-Web app streams OAuth device-code flow progress using Server-Sent Events SSE in Next.js auth routes for real-time browser updates without polling.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: deep-dive
- Published: 2026-08-18

---

**The Pi-Web app streams OAuth device-code authentication progress to the browser using Server-Sent Events (SSE) in a Next.js API route, yielding real-time updates without client-side polling.**

Pi-Web implements a server-driven OAuth device-code flow that keeps users informed of authentication status through a persistent SSE connection. This architecture eliminates the need for repetitive polling from the frontend while providing immediate feedback as users complete authorization on external providers like GitHub or Google.

## Where the SSE Stream Originates

The streaming endpoint lives at `app/api/auth/login/[provider]/route.ts`. This dynamic Next.js App Router segment handles OAuth initiation for any configured provider.

When a client POSTs to this route, the server establishes an SSE connection and delegates the protocol handshake to the Pi SDK's `loginDeviceCode` helper. The route then pipes each stage of the device-code flow back to the browser as discrete JSON events.

## Setting Up the SSE Response Headers

Before streaming begins, the route configures response headers to maintain a persistent, unbuffered connection:

```typescript
// File: app/api/auth/login/[provider]/route.ts
export async function POST(req: NextRequest) {
  const { provider } = req.nextUrl.query;
  const { clientId, scopes } = await req.json();

  const res = new NextResponse();
  res.headers.set('Content-Type', 'text/event-stream');
  res.headers.set('Cache-Control', 'no-cache');
  res.headers.set('Connection', 'keep-alive');
  res.flushHeaders(); // Flush immediately; prevents buffering middleware delays

  // ... device-code flow implementation
}

```

The `text/event-stream` content type signals to browsers that this connection follows the SSE specification. `flushHeaders()` ensures the headers reach the client instantly, which matters for proxies or Next.js edge runtimes that might otherwise buffer the response.

## Streaming the Device-Code Iterator

The core of the implementation consumes an async iterator from `auth.loginDeviceCode()`, writing each yielded value as an SSE message:

```typescript
// File: app/api/auth/login/[provider]/route.ts (continued)
  try {
    const iterator = auth.loginDeviceCode(provider, { clientId, scopes });

    for await (const step of iterator) {
      const payload = { type: step.kind, data: step };
      res.write(`data: ${JSON.stringify(payload)}\n\n`);
    }

    // Final token payload after successful authorization
    res.write(`data: ${JSON.stringify({ 
      type: 'finished', 
      token: iterator.token 
    })}\n\n`);

  } catch (e) {
    res.write(`data: ${JSON.stringify({ 
      type: 'error', 
      message: e.message 
    })}\n\n`);
  } finally {
    res.end();
  }

  return res;
}

```

The `\n\n` terminator follows SSE protocol requirements, marking message boundaries. Each payload uses a `type` discriminator so the client can branch its handling logic without parsing provider-specific response shapes.

## Client-Side EventSource Consumption

The frontend establishes the connection using the native `EventSource` API. The [`useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/useAgentSession.ts) hook manages this lifecycle:

```typescript
// File: hooks/useAgentSession.ts
export function useDeviceCodeLogin(provider: string) {
  const [state, setState] = useState<{
    step?: 'await_user' | 'complete';
    url?: string;
    userCode?: string;
    error?: string;
  }>({});

  useEffect(() => {
    const source = new EventSource(`/api/auth/login/${provider}`);

    source.onmessage = (e) => {
      const { type, data } = JSON.parse(e.data);

      switch (type) {
        case 'device_code':
          setState({
            step: 'await_user',
            url: data.verification_uri,
            userCode: data.user_code
          });
          break;

        case 'finished':
          // Token received; establish session
          setState({ step: 'complete' });
          source.close();
          break;

        case 'error':
          setState({ error: data.message });
          source.close();
          break;
      }
    };

    source.onerror = () => {
      setState({ error: 'Connection lost' });
      source.close();
    };

    return () => source.close();
  }, [provider]);

  return state;
}

```

The hook cleanly separates connection teardown from component unmounting, preventing memory leaks and dangling SSE connections.

## Event Types in the Stream

The SSE payload structure follows a consistent schema across all providers:

| Event Type | Source | Purpose |
|------------|--------|---------|
| `device_code` | `loginDeviceCode` iterator initial yield | Provides `user_code`, `verification_uri`, `expires_in`, `interval` for user display |
| `token_pending` | Iterator polling loop intermediate yield | Optional heartbeat indicating active polling; not present in all SDK versions |
| `finished` | Iterator final resolution | Delivers the authorized `token` and signals stream completion |
| `error` | Catch block or iterator rejection | Transmits failure reason (expired code, user denial, network error) |

This unified interface lets UI components render provider-agnostic progress indicators while the server handles provider-specific protocol details.

## Error Handling and Stream Cleanup

The route guarantees connection closure in all paths:

- **Success**: Stream ends after `finished` event with `res.end()`
- **Exception**: Error payload transmitted before `finally` block terminates connection
- **Client disconnect**: `EventSource` closure on the browser side triggers server-side connection cleanup via standard HTTP connection teardown

The `loginDeviceCode` iterator itself respects the `expires_in` deadline from the OAuth provider, yielding an error rather than polling indefinitely.

## Summary

- **Primary endpoint**: `app/api/auth/login/[provider]/route.ts` establishes SSE connections for OAuth device-code flows
- **SDK integration**: `auth.loginDeviceCode()` provides an async iterator that abstracts provider-specific polling
- **Protocol compliance**: Double-newline terminators and proper headers ensure cross-browser SSE compatibility
- **Client architecture**: [`useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/useAgentSession.ts) consumes the stream via `EventSource`, dispatching typed events to React state
- **Resource safety**: Guaranteed stream closure on success, error, or component unmount prevents connection leaks

## Frequently Asked Questions

### How does the server prevent SSE connection timeouts during long polling periods?

The `loginDeviceCode` iterator in [`lib/web-auth.ts`](https://github.com/agegr/pi-web/blob/main/lib/web-auth.ts) implements the provider's specified `interval` between token requests, typically 5 seconds. Each iteration yields control back to the event loop, keeping the Node.js process responsive. The SSE connection itself remains idle between writes, which most proxies and load balancers tolerate indefinitely when `Connection: keep-alive` is present.

### Can multiple concurrent device-code flows run from the same browser session?

Yes. Each `EventSource` instance creates an independent HTTP connection to `/api/auth/login/[provider]`. The route handler maintains no server-side session state for the SSE stream—all context lives in the `loginDeviceCode` iterator's closure. Multiple tabs or providers can authenticate simultaneously without cross-interference.

### What happens if the user closes the browser before completing authorization?

The browser's `EventSource` termination closes the underlying TCP connection. The Next.js runtime detects this and aborts the ongoing request handler, which propagates through the async iterator as a cancellation signal. The Pi SDK's `loginDeviceCode` implementation respects this signal and stops polling the provider's token endpoint, preventing wasted requests after client departure.