npm package: @meistrari/auth-cli
Description: CLI-friendly SDK for the OAuth Device Authorization Grant flow exposed by `@meistrari/auth-api`. Wraps `@meistrari/auth-core` with an event-emitting `DeviceFlow` class, optional token storage, transparent refresh, and cancellation via `AbortSignal`.
Latest version: 1.0.1

# @meistrari/auth-cli

CLI-friendly SDK for the OAuth Device Authorization Grant flow exposed by
`@meistrari/auth-api`. Wraps `@meistrari/auth-core` with an event-emitting
`DeviceFlow` class, optional token storage, transparent refresh, and
cancellation via `AbortSignal`.

## Install

```sh
bun add @meistrari/auth-cli
```

Inside this monorepo, use the workspace version:

```json
{
    "dependencies": {
        "@meistrari/auth-cli": "workspace:*"
    }
}
```

## Quickstart

```ts
import { DeviceFlow } from '@meistrari/auth-cli'

const flow = new DeviceFlow({
    apiUrl: 'https://auth.example.com',
    requesterApplicationId: 'YOUR-CLI-APP-ID',
    targetApplicationId: 'TARGET-APP-ID',
})

flow.on('userCode', ({ userCode, verificationUriComplete }) => {
    console.log(`Visit ${verificationUriComplete} and enter ${userCode}`)
})

const tokens = await flow.authenticate()
console.log('Logged in as', tokens.user.email)
```

## API

### `new DeviceFlow(options)`

```ts
type DeviceFlowOptions = {
    requesterApplicationId: string
    targetApplicationId: string
    storage?: TokenStorage
    signal?: AbortSignal
} & (
    | { apiUrl: string, fetchOptions?: BetterFetchOption, client?: never }
    | { client: AuthClient, apiUrl?: never, fetchOptions?: never }
)
```

**Two construction modes:**

- **`apiUrl` mode (recommended).** Pass `apiUrl` (and optionally
  `fetchOptions`). The SDK creates and owns its own `AuthClient`.
- **`client` mode (advanced).** Pass an existing `AuthClient`. Use this
  only if you have a configured client to reuse (multi-tenant, custom
  interceptors). **If you don't know which to pick, use `apiUrl`.**

### `flow.getUserCode(): Promise<UserCodeResponse>`

Starts the device authorization flow and returns the user code +
verification URLs. Idempotent: subsequent calls return the cached
response without making another HTTP request.

```ts
const { userCode, verificationUriComplete, expiresIn, interval } = await flow.getUserCode()
```

### `flow.authenticate(): Promise<StoredTokens>`

The main entry point. Behavior:

1. If `storage` is configured AND `getUserCode()` was not called manually
   first, call `storage.load()`.
   - If cached tokens are still valid then return them (emits `success`).
   - If expired, try `refresh()` transparently.
   - If `RefreshTokenExpiredError`, clear storage and fall through to
     device flow.
   - If any other refresh error, re-throw and do not fall through.
2. Call `getUserCode()` (no-op if already called).
3. Poll until tokens come back, then `storage.save()` and emit `success`.

### `flow.refresh(refreshToken?: string): Promise<StoredTokens>`

Refresh the access token manually. If `refreshToken` is omitted, the
SDK loads it from storage. Throws if there is no token to use.

### `flow.on(event, listener)` / `flow.once(event, listener)` / `flow.off(event, listener)`

Subscribe to lifecycle events. The SDK only exposes `on`/`once`/`off` —
emission is internal.

| Event | Payload | When |
|-------|---------|------|
| `userCode` | `UserCodeResponse` | After the device authorization request succeeds |
| `beforePoll` | `{ attempt, interval, elapsedMs }` | Before each poll attempt |
| `pending` | `{ attempt, elapsedMs }` | When the server returns `authorization_pending` |
| `slowDown` | `{ previousInterval, newInterval }` | When the server asks the client to slow down (interval += 5s) |
| `transientError` | `{ attempt, error }` | When the server returns a 5xx (will retry) |
| `success` | `StoredTokens` | When tokens are obtained (poll, refresh, or cache hit) |
| `aborted` | `{ reason? }` | When the `AbortSignal` was tripped |

## Token Storage

Implement `TokenStorage` to persist tokens between runs.

```ts
interface TokenStorage {
    load: () => Promise<StoredTokens | null>
    save: (tokens: StoredTokens) => Promise<void>
    clear: () => Promise<void>
}
```

### Built-in: `JsonFileStorage`

The SDK ships a `JsonFileStorage` that persists tokens as a JSON file
on disk. It creates parent directories on demand and writes with
restrictive permissions (`0o600` by default) so only the current user
can read the file. Missing files are treated as a no-op by `load()`
and `clear()`.

```ts
import { homedir } from 'node:os'
import { join } from 'node:path'
import { DeviceFlow, JsonFileStorage } from '@meistrari/auth-cli'

const storage = new JsonFileStorage(join(homedir(), '.myapp', 'tokens.json'))

const flow = new DeviceFlow({
    apiUrl: 'https://auth.example.com',
    requesterApplicationId: 'CLI',
    targetApplicationId: 'API',
    storage,
})
```

Options:

```ts
type JsonFileStorageOptions = {
    /** Indentation for `JSON.stringify`. Defaults to `2`. Pass `0` to disable. */
    indent?: number
    /** File mode used when writing. Defaults to `0o600`. */
    mode?: number
}
```

For something more secure on macOS, implement `TokenStorage` against
the Keychain via `security` or a native binding. The SDK doesn't care.

## Cancellation

Pass an `AbortSignal` to cancel the polling loop. The signal interrupts
the sleep between polls (and, in `apiUrl` mode, the in-flight fetch).

```ts
import { DeviceFlow, DeviceFlowAbortedError } from '@meistrari/auth-cli'

const flow = new DeviceFlow({
    apiUrl: 'https://auth.example.com',
    requesterApplicationId: 'CLI',
    targetApplicationId: 'API',
    signal: AbortSignal.timeout(2 * 60 * 1000), // 2 minutes
})

try {
    await flow.authenticate()
}
catch (err) {
    if (err instanceof DeviceFlowAbortedError) {
        console.error('Login cancelled or timed out')
    }
    throw err
}
```

In `client` mode, the SDK does NOT wire the signal into your injected
`AuthClient`. You're responsible for setting it up there yourself.

## Errors

| Error | When | Action |
|-------|------|--------|
| `DeviceAuthorizationPendingError` | User has not yet approved | Internal — handled by retry, surfaced via `pending` event |
| `DeviceAuthorizationSlowDownError` | Server asks to slow down | Internal — interval increases by 5s, surfaced via `slowDown` event |
| `DeviceTransientServerError` | 5xx from server | Internal — retried, surfaced via `transientError` event |
| `DeviceAccessDeniedError` | User explicitly denied | **Terminal** — `authenticate()` rejects |
| `DeviceCodeExpiredError` | Device code expired | **Terminal** — `authenticate()` rejects |
| `RefreshTokenExpiredError` | Refresh token revoked or expired | Internal in `authenticate()` — clears storage and falls through to device flow. In `refresh()`, propagates. |
| `DeviceFlowAbortedError` | `signal` was aborted | **Terminal** — `authenticate()` rejects, `aborted` event fires first |

All errors except `DeviceFlowAbortedError` are re-exported from
`@meistrari/auth-core`.

## Refresh

Tokens are refreshed automatically inside `authenticate()` when the
cached access token is expired (and storage is configured). For manual
refresh:

```ts
const fresh = await flow.refresh() // loads from storage
// or
const fresh = await flow.refresh('explicit-refresh-token')
```

## License

Internal Meistrari package.