@meistrari/auth-cli
v1.4.0
Published
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`.
Maintainers
Keywords
Readme
@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
bun add @meistrari/auth-cliInside this monorepo, use the workspace version:
{
"dependencies": {
"@meistrari/auth-cli": "workspace:*"
}
}Quickstart
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)
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:
apiUrlmode (recommended). PassapiUrl(and optionallyfetchOptions). The SDK creates and owns its ownAuthClient.clientmode (advanced). Pass an existingAuthClient. Use this only if you have a configured client to reuse (multi-tenant, custom interceptors). If you don't know which to pick, useapiUrl.
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.
const { userCode, verificationUriComplete, expiresIn, interval } = await flow.getUserCode()flow.authenticate(): Promise<StoredTokens>
The main entry point. Behavior:
- If
storageis configured ANDgetUserCode()was not called manually first, callstorage.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.
- If cached tokens are still valid then return them (emits
- Call
getUserCode()(no-op if already called). - Poll until tokens come back, then
storage.save()and emitsuccess.
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.
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().
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:
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).
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:
const fresh = await flow.refresh() // loads from storage
// or
const fresh = await flow.refresh('explicit-refresh-token')License
Internal Meistrari package.
