Skip to content

v3.0.0

Choose a tag to compare

@koistya koistya released this 27 Sep 17:17
10b1a83

oauth-callback gets a CLI tool or desktop app through the browser half of OAuth. It opens the user's browser, listens on a loopback redirect URI (RFC 8252), and hands you a validated authorization code, on Node.js, Deno and Bun. For MCP clients, oauth-callback/mcp adds the browser, listener and credential storage to the official MCP TypeScript SDK in one line.

v3 is a ground-up redesign around a smaller, harder-to-misuse API. It's a breaking release: see Upgrading from v2.

Highlights

  • One function, no port juggling. Give getAuthCode() a builder and it binds a free loopback port, hands you the exact redirectUri and a fresh state, opens the browser, and returns the code. No more port/hostname/callbackPath that had to match your URL.
  • Secure by default. Every flow has a state, and only a callback that matches it exactly can finish the flow; stray or forged requests are refused (400, or 404/405 for a wrong path or method) and the flow keeps waiting. Authorization URLs are validated before the browser opens, and callback pages never render callback data.
  • MCP in one call. await auth.connect(client) handles the whole 401 → browser → token → reconnect dance, and completes a pending step-up when you call it again. The MCP SDK v2 does the OAuth itself: discovery, dynamic client registration, PKCE, refresh tokens, issuer checks.
  • Zero runtime dependencies. The browser launcher is bundled and loaded only when used.

Getting started

npm install oauth-callback   # or: bun add oauth-callback
import { getAuthCode } from "oauth-callback";

const { code, redirectUri } = await getAuthCode(() => {
  const url = new URL("https://github.com/login/oauth/authorize");
  url.searchParams.set("client_id", CLIENT_ID);
  return url; // redirect_uri and state are appended
});

await exchangeCode({ code, redirectUri }); // your token request

With the MCP SDK (npm install @modelcontextprotocol/client):

import { Client } from "@modelcontextprotocol/client";
import { homedir } from "node:os";
import { join } from "node:path";
import { browserAuth, fileStore } from "oauth-callback/mcp";

const auth = browserAuth({
  serverUrl: "https://mcp.notion.com/mcp",
  redirectUri: "http://127.0.0.1:8765/callback",
  clientName: "Acme CLI",
  store: fileStore(join(homedir(), ".config/acme/notion.json")),
});

const client = new Client({ name: "acme", version: "1.0.0" });
await auth.connect(client); // opens the browser only when needed

Try it without any account: bun run example:demo in a clone of this repo. More in the README.

What's new

Core (oauth-callback)

  • getAuthCode(authorization, options?) takes a builder ({ redirectUri, state, signal }) => url or a prebuilt URL, and resolves with { code, redirectUri, params }. redirectUri is the exact redirect URI string, never re-serialized: when the authorization request carried redirect_uri, send it verbatim in your token request.
  • Builders are the way to use PAR and JAR: the listener is bound first, and signal aborts your pushed request along with the flow.
  • Default redirect host is 127.0.0.1 (RFC 8252 §8.3), with an ephemeral port for builders.
  • launch(url) replaces launch: true/false: log the URL for SSH sessions or render a QR code. If the launcher rejects, the flow fails with that error.
  • Timeout and abort reject with the signal's reason; a timeout is a DOMException named TimeoutError. The default timeout is 5 minutes.
  • OAuthCallbackError (error, description, uri, params) for error callbacks. Its message carries only the error code, so it's safe to log.
  • Callback pages send CSP, Cache-Control: no-store, Referrer-Policy: no-referrer and nosniff. Custom successHtml/errorHtml is served verbatim.

MCP (oauth-callback/mcp)

  • Built on @modelcontextprotocol/client 2.1+ (optional peer dependency). Refresh tokens now work, handled by the SDK; opt into offline_access with clientMetadata: { grant_types: ["authorization_code", "refresh_token"] }.
  • auth.connect(client) for the common case; auth.completeAuthorization(transport) when you bring your own transport.
  • CredentialStore is just load() and save(text | undefined), so a keychain store is four lines. Use one store per MCP server. fileStore(absolutePath) writes atomically, owner-only (0600) on POSIX.
  • Sign out with await client.close(); await auth.invalidateCredentials("all"), which also ends any connect() still in progress.
  • One interactive flow per provider: concurrent connect() calls share one browser trip and one registration.

Platform

  • Node.js 22+, Deno 2, Bun 1.2+. CI smoke-tests the built package on each minimum version; browserAuth() also depends on what the MCP SDK supports.
  • Tested against the real MCP SDK on both sides: SDK client, SDK resource server, and a strict authorization server.

Upgrading from v2

  • Node.js 22+, and @modelcontextprotocol/client 2.1+ in place of @modelcontextprotocol/sdk 1.x.
  • getAuthCode({ authorizationUrl, port, ... }) becomes getAuthCode(url, options?). Move port/hostname/callbackPath into the URL's redirect_uri or the redirectUri option, or use a builder and let the library choose.
  • The result is { code, redirectUri, params }: read state and other values with params.get().
  • OAuthError becomes OAuthCallbackError (description/uri instead of error_description/error_uri), and the TimeoutError class becomes a DOMException.
  • launch: false becomes launch: (url) => console.log(url); custom launchers receive a URL, and a rejection fails the flow.
  • Removed: onRequest, getRedirectUrl() (use the builder's redirectUri), and the root mcp namespace and store exports (import from oauth-callback/mcp).
  • Stricter input, checked before the browser opens: https: or loopback http: authorization URLs, response_type=code, response_mode=query, and timeout as integer milliseconds. Prebuilt PAR/JAR URLs are rejected: use a builder.
  • Callback data is no longer templated into pages: errorHtml placeholders are gone.
  • Register a loopback redirect with your provider: http://127.0.0.1/callback if it allows any loopback port (RFC 8252 §7.3), otherwise a fixed http://127.0.0.1:8765/callback passed as redirectUri.
  • MCP: browserAuth() requires serverUrl, a fixed-port redirectUri, and either clientName (for dynamic registration) or clientInformation: { client_id, client_secret?, issuer } in place of clientId/clientSecret. scope moves to clientMetadata.scope, authTimeout to timeout, and the connect-retry boilerplate becomes auth.connect(client).
  • MCP: TokenStore/storeKey become one CredentialStore per server (in memory by default), and fileStore() needs an absolute path. v2 token files aren't read, so users authorize once after upgrading.

The full guide, with before/after code and a complete option table, is in Migrating to v3. The reasoning behind the design lives in ADR-004 to ADR-008.

Thanks

Big thanks to @AbhikRao for automatic state validation and ignoring invalid callbacks (#54), and for the end-to-end test against the real MCP SDK (#61). Both shaped this release. 🙌

Full Changelog: v2.2.1...v3.0.0