CLI Auth · Drop-in OAuth for your CLI

Production-grade OAuth for your CLI

A batteries-included, fully pluggable authentication library. Four flows. Token storage, refresh, cross-process locking, keychain integration. Every piece handled, every piece swappable.

$ npm install cli-auth

View on GitHub

MIT license
Node.js 22+
Bun 1.1+
ESM only
TypeScript

~/projects/your-cli — zsh
❯ your-cli login
Not signed in yet.
Asking your provider for a device code…
Open https://your-tenant.logto.app/device and enter this code:
A H 7 - 3 K 9 P
Waiting for sign-in

Four flows. One library.

Pick a strategy. The plumbing comes with it.

Device code (RFC 8628)

The one you want for servers, Docker shells, and any box where opening a browser isn't an option.

When to reach for this → Headless, SSH, or remote. Anywhere your CLI lives somewhere a browser cannot reach. The user signs in from their laptop while your CLI polls in the background.

import { createCliAuth, keyringStorage } from 'cli-auth';
import { Entry } from '@napi-rs/keyring';

const auth = createCliAuth({
  strategy: 'device-code',
  provider: {
    metadata: {
      deviceAuthorizationEndpoint: 'https://your-tenant.logto.app/oidc/device/auth',
      tokenEndpoint: 'https://your-tenant.logto.app/oidc/token',
    },
  },
  clientId: 'your-cli-client',
  storage: keyringStorage({ entry: new Entry('your-cli', 'tokens') }),
  scope: 'openid offline_access',
});

await auth.login({
  onAuthorization: ({ userCode, verificationUri }) => {
    console.log(`Visit ${verificationUri} and enter ${userCode}`);
  },
});

const accessToken = await auth.getToken();

The stuff nobody tells you CLI auth needs.

Every note below is a real bug, race condition, or spec clause we've already handled.

…and tests.

Swap any piece. Nothing is a black box.

Storage, locking, fetch, and the callback page are all hooks. Nothing hardcoded. Click a slot and watch the config rewrite itself.

Storage Lock Fetch Callback Path Callback Page
OS keychain None Default /callback Built-in page

Swap the storage

Storage Options
OS keychain
File (0600)
In-memory
Your own

macOS Keychain, Windows Credential Store, or Linux Secret Service, whichever the OS provides. Via @napi-rs/keyring.

import { createCliAuth } from 'cli-auth';

const auth = createCliAuth({
  strategy: 'authorization-code',
  provider: { /* ... */ },
  clientId: 'your-cli-client',
  storage: keyringStorage({ entry: new Entry('your-cli', 'tokens') }),
});

Bring your own identity provider

Any OAuth 2.0 / OIDC-compliant server works. Point the library at your endpoints and it runs. We didn't build this only for Logto.

Questions people ask

How is this different from openid-client?

openid-client is great. But it's a protocol library, not a CLI auth library, and the plumbing on top is still on you. cli-auth is that plumbing: token storage with sane defaults, automatic refresh, a cross-process lock so two shells don't burn each other's refresh token, keychain integration, per-resource caching. Write it once here instead of once per CLI.

Does it work with Auth0, Okta, or my own provider?

Yes. Any OIDC-compliant provider. Hand it the endpoint metadata and it runs the flows. We don't care who issues your tokens.

How does cross-process locking actually work?

A file lock via atomic O_CREAT | O_EXCL. Once the lock is held, the library re-reads the credential from storage and checks whether another process already refreshed. Without that recheck, two shells racing to refresh would invalidate each other's refresh token. Refresh tokens are usually single-use.

What if the OS keyring isn't available?

Use fileStorage. It writes the credential with 0600 permissions so only your user account can open it, and other users on the box get permission-denied. Writes are atomic, so a crash mid-save can't corrupt the file. Detect your environment at runtime (headless server, Docker, SSH) and pick the backend that fits. You're not locked to one.

Which runtimes are supported?

Node.js 22+ and Bun 1.1+. The library is ESM-only and leans on built-ins (fetch, crypto) that behave the same across both. CommonJS require() is not supported.

Can I brand the post-login callback page?

Yes. The authorization-code flow takes callbackPath and callbackSource. Put callbackPath wherever you want (/auth/callback, /cli-done, whatever) and ship your own HTML through callbackSource. No generic "you can close this window" page. Your CLI, your brand.