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
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)
- Authorization code + PKCE (RFC 7636)
- Token exchange (RFC 8693)
- Client credentials (RFC 6749 §4.4)
Device code (RFC 8628)
The one you want for servers, Docker shells, and any box where opening a browser isn't an option.
- Polling loop handles slow_down back-off and the expiry deadline for you
- User code surfaced through an onAuthorization callback so you decide how to show it
- Refreshes automatically when the access token nears expiry
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.
- slow_down
- back-off
- PKCE verifier + challenge
- loopback on 127.0.0.1
- O_CREAT | O_EXCL
- atomic write + rename
- cross-process lock
- recheck after lock
- 0600 permissions
- macOS Keychain
- Windows Credential Store
- Linux Secret Service
- refresh token single-use
- expiry threshold
- per-resource token cache
- proxy / OTel fetch hook
- branded callback page
- custom parameters
- TypeScript discriminated unions
…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.
- Logto
- Auth0
- Okta
- Keycloak
- Microsoft Entra
- Authelia
- Any OIDC 2.0 server
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.