Repository navigation
v3.0.0
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 exactredirectUriand a freshstate, opens the browser, and returns the code. No moreport/hostname/callbackPaththat 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-callbackimport { 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 requestWith 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 neededTry 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 }) => urlor a prebuilt URL, and resolves with{ code, redirectUri, params }.redirectUriis the exact redirect URI string, never re-serialized: when the authorization request carriedredirect_uri, send it verbatim in your token request.- Builders are the way to use PAR and JAR: the listener is bound first, and
signalaborts 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)replaceslaunch: 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
DOMExceptionnamedTimeoutError. 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-referrerandnosniff. CustomsuccessHtml/errorHtmlis served verbatim.
MCP (oauth-callback/mcp)
- Built on
@modelcontextprotocol/client2.1+ (optional peer dependency). Refresh tokens now work, handled by the SDK; opt intooffline_accesswithclientMetadata: { grant_types: ["authorization_code", "refresh_token"] }. auth.connect(client)for the common case;auth.completeAuthorization(transport)when you bring your own transport.CredentialStoreis justload()andsave(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 anyconnect()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/client2.1+ in place of@modelcontextprotocol/sdk1.x. getAuthCode({ authorizationUrl, port, ... })becomesgetAuthCode(url, options?). Moveport/hostname/callbackPathinto the URL'sredirect_urior theredirectUrioption, or use a builder and let the library choose.- The result is
{ code, redirectUri, params }: readstateand other values withparams.get(). OAuthErrorbecomesOAuthCallbackError(description/uriinstead oferror_description/error_uri), and theTimeoutErrorclass becomes aDOMException.launch: falsebecomeslaunch: (url) => console.log(url); custom launchers receive aURL, and a rejection fails the flow.- Removed:
onRequest,getRedirectUrl()(use the builder'sredirectUri), and the rootmcpnamespace and store exports (import fromoauth-callback/mcp). - Stricter input, checked before the browser opens:
https:or loopbackhttp:authorization URLs,response_type=code,response_mode=query, andtimeoutas integer milliseconds. Prebuilt PAR/JAR URLs are rejected: use a builder. - Callback data is no longer templated into pages:
errorHtmlplaceholders are gone. - Register a loopback redirect with your provider:
http://127.0.0.1/callbackif it allows any loopback port (RFC 8252 §7.3), otherwise a fixedhttp://127.0.0.1:8765/callbackpassed asredirectUri. - MCP:
browserAuth()requiresserverUrl, a fixed-portredirectUri, and eitherclientName(for dynamic registration) orclientInformation: { client_id, client_secret?, issuer }in place ofclientId/clientSecret.scopemoves toclientMetadata.scope,authTimeouttotimeout, and the connect-retry boilerplate becomesauth.connect(client). - MCP:
TokenStore/storeKeybecome oneCredentialStoreper server (in memory by default), andfileStore()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