npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jetit/cg-oauth

v0.2.2

Published

Coastguard OAuth 2.0 SDK — browser + Node.js client for authorization code, PKCE, device flow, refresh, introspection, and revocation.

Readme

@jetit/cg-oauth

Log users into your app with Coastguard. Works in browsers and Node.js.

Contents

Install

npm install @jetit/cg-oauth

Set up once

Tell the SDK where your Coastguard server is and who you are.

import { initializeOAuthClient } from '@jetit/cg-oauth';

initializeOAuthClient({
    serverUrl: 'https://cg.surfauth.com',
    clientId: 'your-client-id',
    clientSecret: 'your-client-secret', // skip for browser-only public clients
});

That's it. Now you can call any function below.


Log a CLI in (one line)

For CLIs running on a user's machine, skip the protocol plumbing and call login(). It auto-picks the right flow — opens a browser on desktops, falls back to the "enter this code" dance over SSH.

import { login } from '@jetit/cg-oauth'; // Node entry

const tokens = await login({ scope: 'openid profile' });
// → { accessToken, refreshToken, tokenType, expiresIn }

Want SSO from your own customer dashboard? Pass a verificationUri that you've registered on your OAuth client:

const tokens = await login({
    scope: 'openid profile',
    verificationUri: 'https://dashboard.acme.com/cli/connect',
});

The user opens that URL in a browser, is already logged in, sees an "Approve CLI access?" button, clicks it — your CLI gets tokens. See CLI ↔ your own dashboard for how to build the approval route.

Pin the flow if you don't want the auto-picker: login({ flow: 'loopback' }) or login({ flow: 'device' }).


Log a user in (5 steps)

Standard web login. User clicks "Log in", goes to Coastguard, signs in, comes back to your app with a code, you turn that code into a token.

import {
    generatePKCE,
    initiateOAuthSession,
    buildAuthorizeUrl,
    exchangeCodeForToken,
} from '@jetit/cg-oauth';

// 1. Make a PKCE pair (security secret + matching challenge)
const pkce = await generatePKCE();

// 2. Ask Coastguard to start a login session
const { sessionId } = await initiateOAuthSession({
    redirectUri: 'https://yourapp.com/callback',
    pkce,
});

// 3. Send the user to Coastguard
const url = buildAuthorizeUrl({
    redirectUri: 'https://yourapp.com/callback',
    pkce,
    sessionId,
});
window.location.href = url;

// 4. After login, Coastguard redirects to your callback with `?code=...`
//    Read the code from the URL.

// 5. Swap the code for tokens
const tokens = await exchangeCodeForToken(code, 'https://yourapp.com/callback', pkce.codeVerifier);
// → { accessToken, refreshToken, tokenType, expiresIn }

Save pkce.codeVerifier between step 1 and step 5 (e.g., in sessionStorage). You need it after the redirect.


Log in without a browser (3 steps)

Use this for CLIs, smart TVs, or any device that can't open a web page.

import { initiateDeviceAuthorization, pollForDeviceToken } from '@jetit/cg-oauth';

// 1. Get a code
const auth = await initiateDeviceAuthorization({ scope: 'openid profile' });

// 2. Tell the user where to go
console.log(`Open ${auth.verificationUri} and enter ${auth.userCode}`);

// 3. Wait for them to finish
const tokens = await pollForDeviceToken(auth.deviceCode, auth.interval * 1000);
// → { accessToken, refreshToken, tokenType, expiresIn }

Manage tokens

import { refreshAccessToken, introspectToken, revokeToken } from '@jetit/cg-oauth';

// Get a fresh access token using the refresh token
const fresh = await refreshAccessToken(tokens.refreshToken);

// Check if a token is still valid
const info = await introspectToken(tokens.accessToken);
if (info.active) { /* good */ }

// Log out (kill the token on the server)
await revokeToken(tokens.refreshToken);

camelCase or snake_case?

By default, every response uses camelCase (accessToken, expiresIn).

If your code already expects the OAuth standard snake_case (access_token, expires_in), switch it once at startup:

initializeOAuthClient({
    serverUrl: '...',
    clientId: '...',
    responseCase: 'snake', // default is 'camel'
});

Or flip it any time:

import { setResponseCase } from '@jetit/cg-oauth';

setResponseCase('snake');
setResponseCase('camel');

Need just one call in snake_case? Use the *Raw version of any function — it always returns snake_case, no matter the setting:

import { exchangeCodeForTokenRaw } from '@jetit/cg-oauth';

const raw = await exchangeCodeForTokenRaw(code, redirectUri, codeVerifier);
// → { access_token, refresh_token, token_type, expires_in }

CLI ↔ your own dashboard

Use this when your customers already log into a dashboard you built (e.g. dashboard.acme.com) and you want their CLI to reuse that session instead of asking for credentials again.

The flow

CLI                    Coastguard backend          Your dashboard
 │                          │                            │
 │ login({ verificationUri }) ──►                        │
 │ ◄── deviceCode + userCode + URL                        │
 │ prints URL + code + confirmation phrase                │
 │                                                        │
 │     (user opens URL in browser)  ─────────────────────►│
 │                                                        │
 │                          ◄── GET /device/info          │
 │                          ── client name + scopes ────► │
 │                                                        │
 │                          ◄── POST /device/approve      │
 │                          ── 200 ─────────────────────► │
 │                                                        │
 │ ◄── poll returns tokens                                │

Dashboard side

You implement a route at the exact path you registered in deviceVerificationUris (e.g. GET /cli/connect?user_code=ABCD-1234).

import { getDeviceCodeInfo, approveDeviceCode, denyDeviceCode } from '@jetit/cg-oauth';

// Step 1 — fetch details so the user knows what they're approving.
const info = await getDeviceCodeInfo(userCode, accessToken);
// info: { clientId, clientName, logoUrl, scopes[], userCode,
//         confirmationPhrase, initiatorHint, requestedAt, expiresAt, status }

// Step 2 — render the approval page. Required elements:
//   - Client name + logo
//   - Scope list (info.scopes) with descriptions
//   - User_code DISPLAYED PROMINENTLY: "Confirm this matches your terminal"
//   - Confirmation phrase DISPLAYED PROMINENTLY: info.confirmationPhrase
//   - Initiator hint if present: "CLI initiated 12s ago from hostname=...,
//     ip_country=..."
//   - Approve and Deny buttons — Deny at least as visually prominent as Approve

// Step 3 — on button click, call the matching endpoint.
await approveDeviceCode(userCode, accessToken); // status=approved
// or
await denyDeviceCode(userCode, accessToken);    // status=denied

// Step 4 — success screen. The CLI may still be polling — tell the user to wait.
//   "Approved. Return to your CLI. It may take up to 10 seconds."

A working reference page lives at examples/cli-connect.html inside this package (also in the published tarball under node_modules/@jetit/cg-oauth/examples/). Fork it.

Security requirements (read this)

  1. Never put the access token in localStorage or sessionStorage. XSS on your dashboard equals device approval for any attacker. Use an httpOnly cookie with a thin BFF, or a session-bound server-side store.
  2. Request the minimum scope. Token should carry device.approve only (+ openid profile if you need user identity for your own UI). A leaked broad-scope token becomes a weapon.
  3. Short TTL. Mint tokens used for /device/approve with ≤ 15 minutes access-token lifetime. Refresh is fine for the user session; approval tokens should rotate fast.
  4. HTML-escape user_code if you read it from the URL. User-controlled input. Never interpolate into innerHTML without escaping.
  5. Display the confirmationPhrase verbatim — don't reformat, don't translate. Shared human-checkable signal that matches what the CLI is printing. Reformatting defeats the anti-phish match.
  6. Reject approvals without session reuse. If your session is missing, expired, or was initiated from a different IP than the current request, require a fresh login — do not fall through to the approval page.
  7. Rate-limit your own route too. Malicious browser extension running on your dashboard origin could spam approvals if your route is wide-open. Coastguard rate-limits the backend; you should rate-limit the outer /cli/connect route as well.

Copy checklist

  • Primary heading: "Let {clientName} access your account?"
  • Subheading: the user_code in fixed-width font.
  • Match prompt: "Confirm this matches the code in your CLI: {userCode} with phrase {confirmationPhrase}"
  • Scope list: one row per info.scopes, use scope.title + scope.description + scope.risk badge.
  • Initiator line: "Requested {requestedAt} from {initiatorHint.hostname} ({initiatorApproxLocation.ipCountry})"
  • Expiration: live countdown driven by info.expiresAt (use aria-live="polite").
  • Buttons: "Deny" (secondary but not tiny) and "Allow {clientName}" (primary).
  • Success state: "Approved. You can close this tab — your CLI will pick it up in a few seconds."

Accessibility (WCAG AA)

  • Focus trap on the approve dialog; Escape denies.
  • 4.5:1 contrast minimum on button labels.
  • 44px touch target minimum.
  • Screen-reader labels describe each scope's risk level.
  • Tab order: logo → client name → scopes → user_code → confirmation phrase → Deny → Approve.
  • Countdown announced via aria-live="polite"; does NOT auto-submit on expiry.

Status enum

info.status can return any of:

| Status | Dashboard copy suggestion | |---|---| | pending | Render the approval page. | | approved | "Already approved. Check your CLI." | | already_approved_by_other | "Someone else on this account already approved. Check your CLI." | | denied | "Already denied. Close this tab." | | expired | "Code expired. Run the CLI command again." | | rate_limited | "Too many approval attempts. Try again in {info.retryAfter/60} minutes." |

Dashboards that don't handle every state fall through to generic errors that confuse users.

Common mistakes

  • Reading user_code from window.location.search without HTML-escaping before rendering.
  • Using the CLI's raw verificationUriComplete as the link (user_code auto-fill). Prefer the un-filled verificationUri and make the user type the code — best anti-phish posture.
  • Caching the approval page HTML. Every request should re-fetch info because status changes.
  • Forgetting to call denyDeviceCode on Deny — if you don't, the CLI polls until timeout, which looks broken.

Admin: register two OAuth clients

Checklist for the OAuth admin registering the two clients that make customer-hosted CLI login work.

┌──────────────────┐   approves login for   ┌──────────────────┐
│  Dashboard       │ ─────────────────────▶ │  CLI             │
│  (confidential)  │                        │  (public)        │
│  grants: AuthCode│                        │  grants: device  │
│  scope includes  │                        │  scope: (none    │
│   device.approve │                        │   special)       │
└──────────────────┘                        └──────────────────┘

Two OAuth client records on Coastguard, both in the same realm:

1. The CLI client — public

| Field | Value | |---|---| | clientId | e.g. acme-cli | | clientName | "Acme CLI" (shown on the approval page) | | logoUrl | Optional. Shown on the approval page. | | tokenEndpointAuthMethod | none (public client — no secret) | | grantTypes | ['urn:ietf:params:oauth:grant-type:device_code'] | | redirectUris | ['http://127.0.0.1/callback'] — used only by RFC 8252 loopback. A single loopback entry covers all ports. | | deviceVerificationUris | ['https://dashboard.acme.com/cli/connect'] — EXACT URLs where your dashboard hosts the approval route. One entry per environment (prod + staging). HTTPS required (http://localhost allowed for dev only). | | verifierClientIds | ['acme-dashboard'] — client IDs of dashboards allowed to approve this CLI's device codes. |

Why these fields, in 30 seconds:

  • tokenEndpointAuthMethod=none lets your CLI (public binary) skip client_secret. Relies on PKCE.
  • deviceVerificationUris is exact-URL match — no wildcards. Protects against XSS-exploitable pages on the same host becoming approval surfaces.
  • verifierClientIds is the cross-client trust link. Without it, any dashboard in the same realm could approve your CLI's device codes.

2. The dashboard client — confidential

| Field | Value | |---|---| | clientId | e.g. acme-dashboard | | clientName | "Acme Dashboard" | | tokenEndpointAuthMethod | client_secret_post (only confidential method Coastguard accepts; client_secret_basic is rejected because auth flows read client_secret from the request body, never the Authorization header) | | grantTypes | ['authorization_code', 'refresh_token'] | | redirectUris | ['https://dashboard.acme.com/auth/callback'] | | Allowed scopes | must include device.approve (in addition to openid profile etc.) |

The dashboard runs a standard AuthCode + PKCE login for its users. During that login it requests scope: 'openid profile device.approve'. The resulting access token is what approveDeviceCode / denyDeviceCode call with.

3. Wire the dashboard's clientId into the CLI's verifierClientIds

After both clients exist, update the CLI client's verifierClientIds to include the dashboard client's ID. This is the trust link. Without it, /device/approve returns a uniform 404 to the dashboard and customers spend a day debugging "why is my approve call 404'ing."

Pre-flight checks

  • [ ] CLI client and dashboard client are in the same realm.
  • [ ] Dashboard's redirectUris covers every origin you'll run it from (prod + staging + localhost for dev).
  • [ ] CLI's deviceVerificationUris covers every dashboard origin (URL matching is exact, so each environment is a separate entry).
  • [ ] CLI's verifierClientIds includes the dashboard's clientId.
  • [ ] Dashboard's allowed scopes include device.approve.
  • [ ] device.approve scope is registered in the Coastguard scope catalog.
  • [ ] Backend supports tokenEndpointAuthMethod='none' public-client path.
  • [ ] Backend advertises device.approval.v1 + public-client.v1 in GET /v2/oauth/.well-known/oauth-features.

Common pitfalls

  • Registering a redirect URI in deviceVerificationUris. Different purpose. Redirect URIs are for the AuthCode callback. Verification URIs are where the CLI approval UI lives. Don't mix.
  • Using https://dashboard.acme.com when your page is at https://dashboard.acme.com/cli/connect. Exact match means trailing-path matters. Register the full URL.
  • Forgetting to grant the dashboard the device.approve scope. All approval calls return 403. SDK throws MissingDeviceApproveScopeError.
  • Two CLI clients wired to the same dashboard — fine. But each needs its own verifierClientIds entry. The link is one-way.

Troubleshooting

Symptoms → cause → fix. The SDK throws typed errors on CLI-facing 4xx paths; dashboard approval paths return a uniform 404 on purpose (security). When you hit the 404, the most likely cause is always below.

SDK errors

VerificationUriNotRegisteredError

Backend said your verificationUri doesn't exactly match any entry in your OAuth client's deviceVerificationUris list.

Fix: compare the URL you passed to login({ verificationUri }) against the registered entries. Exact match after canonicalization (lowercased scheme + host, port normalized, query + hash stripped, trailing / removed). If your dashboard URL has a query string, canonicalization strips it — registered entry should be the path only.

CapabilityUnsupportedError

Backend doesn't advertise the feature your SDK needs. Each feature identifier (e.g. device.approval.v1, public-client.v1) is returned from GET /v2/oauth/.well-known/oauth-features.

Fix: upgrade the backend, or pin an older SDK that doesn't require the feature. Check which features the backend currently advertises:

curl https://cg.surfauth.com/v2/oauth/.well-known/oauth-features

MissingDeviceApproveScopeError

Dashboard's access token does not include device.approve scope. Backend returned 403.

Fix: the dashboard's OAuth login must request scope: 'openid profile device.approve' (or whatever your baseline is + device.approve). Re-login the user; their old token lacked the scope.

CliClientSecretRequiredError

You called initializeOAuthClient({ serverUrl, clientId }) without clientSecret, but the backend returned invalid_client. The client isn't registered as public.

Fix: register the client with tokenEndpointAuthMethod: 'none', OR pass clientSecret in initializeOAuthClient.

Uniform 404 from /device/info, /approve, /deny

On purpose. Backend refuses to distinguish "unknown user_code" from "bearer token's client isn't in verifierClientIds" from "wrong realm" so attackers can't enumerate valid codes. If you get 404, check these in order:

  1. user_code expired. TTL is 900s. Ran the CLI 20 minutes ago? Run it again.
  2. Dashboard's client isn't in the CLI's verifierClientIds. Most common dev-time cause. Verify with your Coastguard admin API:
    curl -H "Authorization: Bearer $ADMIN" \
      https://cg.surfauth.com/v2/oauth/clients/acme-cli | jq '.verifierClientIds'
    # Must include 'acme-dashboard'
  3. Different realms. Dashboard token was issued in realm A, CLI is registered in realm B. They must match.
  4. Status not pending. If the device was already approved / denied / expired, /approve returns 404 (finalized). Call /info first to see the current status.

401 on /info or /approve

Bearer token is not active. Either expired, revoked, or malformed. Re-login the user in the dashboard.

429 on /approve / /deny

Rate-limited. Bucket is (userId, remote_ip) — 10 distinct user_codes per hour per user-IP pair.

Fix: wait Retry-After seconds. If you're hitting this in dev, the user-IP pair is too aggressive in the limiter for testing; either clear rl:device-verify:* keys in Redis or switch test IPs.

CORS failures in the browser console

Silent failures here are why every customer loses a day. Backend sets Access-Control-Allow-Origin only when:

  1. Request's Origin header EXACTLY matches one of the device-code's client's registered deviceVerificationUris origins, AND
  2. Response is one of /device/info, /device/approve, /device/deny.

Fix:

  • Verify your dashboard's actual origin (including port) matches what's registered. https://dashboard.acme.comhttps://dashboard.acme.com:443 to some browsers; don't include the default port.
  • Open DevTools → Network tab → look for the OPTIONS preflight. If it's missing an Access-Control-Allow-Origin header, the origin doesn't match.
  • If preflight succeeds but the main request fails, check Access-Control-Allow-Headers — your custom header list must match what the browser is sending.

Loopback flow (loginViaBrowser) hangs

Browser opened, user completed login, CLI still spinning.

  1. Port collision. Default port: 0 picks an ephemeral port. If you forced port: 8080 and something else owns it, the listen fails — you should see an error, not a hang. If you see a hang, check stderr.
  2. DNS rebinding guard tripped. Callback server rejects any request with Host header not in { 127.0.0.1:port, localhost:port, [::1]:port }. If your browser resolves localhost to IPv6 ::1 but Node bound IPv4 only, dual-stack resolution fails. Pin the openUrl to 127.0.0.1 explicitly.
  3. State mismatch. CLI logged "state mismatch — possible CSRF". Something rewrote the query string between authorize and callback. Rare; usually a browser extension or a corporate proxy.

Browser opens to "Your session has expired"

Authorize URL requires a valid session_id from initiateOAuthSession. If you copied an old authorize URL, session has timed out (TTL 600s). Run the CLI again.

Poll returns User denied access but the user didn't click deny

Someone else on the account denied. If this is wrong (shared session), check the audit log for the /device/deny call.

Confirmation phrase doesn't match

Supposed to. Tell the user to stop and start over. CLI's printed phrase and the dashboard's displayed phrase are both derived from the same device_code. If they differ, the user is on a phishing dashboard. Report to security.


Migrating from 0.1.x

Breaking changes

None. 0.2.x is additive — every API that worked in 0.1.x still works.

Deprecations

  • initiateDeviceAuthorization(scope: string) positional form — still works, but emits a one-time runtime warning on each init. Pass an options object instead:
    - await initiateDeviceAuthorization('openid profile')
    + await initiateDeviceAuthorization({ scope: 'openid profile' })
    Scheduled for removal in 0.4.0.

Recommended: switch to the login() one-liner

For CLIs the entire 5-step authorization code + PKCE flow collapses to:

- import { generatePKCE, initiateOAuthSession, buildAuthorizeUrl, exchangeCodeForToken } from '@jetit/cg-oauth';
- const pkce = await generatePKCE();
- const { sessionId } = await initiateOAuthSession({ redirectUri, pkce });
- const url = buildAuthorizeUrl({ redirectUri, pkce, sessionId });
- // … browser redirect, capture code somehow …
- const tokens = await exchangeCodeForToken(code, redirectUri, pkce.codeVerifier);
+ import { login } from '@jetit/cg-oauth';
+ const tokens = await login({ scope: 'openid profile' });

Works on any Node desktop/CI: loopback (spins 127.0.0.1), device (prints a URL + code). Env picker decides.

Recommended: check for device.approval.v1 before using new dashboard helpers

If your backend hasn't rolled out 0.2.x-compatible routes yet, feature-gate:

import { requireFeature, OAuthFeature } from '@jetit/cg-oauth';
await requireFeature(OAuthFeature.DeviceApprovalV1);
// throws CapabilityUnsupportedError if the backend isn't ready

Cached for 1 hour per serverUrl. Safe to call on every request.

responseCase default is 'camel'

First-time 0.2.x upgraders coming from pre-0.1.0: responseCase defaults to 'camel'. If your code reads tokens.access_token you have two options:

  1. Update to tokens.accessToken (recommended — idiomatic JS).
  2. Set responseCase: 'snake' in initializeOAuthClient to preserve old shape. Per-call override: call the *Raw variants (exchangeCodeForTokenRaw, etc.).

New config fields

  • fetch — inject a custom fetch (for tests, HTTP proxies, outbound TLS pinning).
  • logger — inject a { debug, info, warn, error } shape. SDK emits diagnostics at debug + flow milestones at info.

http.ts callers

If you imported post or get directly from @jetit/cg-oauth, signatures are backwards compatible but gained a PostOptions / GetOptions overload:

- await post(url, body, { 'X-Thing': '1' }, signal)
+ await post(url, body, { additionalHeaders: { 'X-Thing': '1' }, signal, bearerToken: token })

Old shape still works.


Errors

Every call throws on failure. The error has .code (e.g., invalid_grant) and .status (HTTP code).

try {
    await exchangeCodeForToken(code, redirectUri, codeVerifier);
} catch (e) {
    console.error(e.code, e.message);
}

For typed CLI-facing diagnostics see Troubleshooting.


TypeScript

All types are exported. Import what you need:

import type { TokenResponse, DeviceAuthResponse, OAuthConfig } from '@jetit/cg-oauth';

Snake variants are TokenResponseRaw, DeviceAuthResponseRaw, etc.


License

Proprietary. All rights reserved.