Skip to content

Core Concepts ​

The redirect URI ​

The redirect URI is the one address option. It must be http: on 127.0.0.1, [::1] or localhost, with no fragment, credentials, duplicate query keys, or callback parameters (state, code, error, error_description, error_uri, iss) in its query. The library listens on it; when the authorization request carries redirect_uri, it must be the same value.

getAuthCode() accepts the authorization request in two forms:

ts
// Builder (recommended): called after the listener binds
await getAuthCode(({ redirectUri, state, signal }) =>
  buildUrl(redirectUri, state),
);

// Prebuilt URL: listens on its redirect_uri, appends state if missing
await getAuthCode(
  "https://auth.example.com/authorize?client_id=app&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback",
);

// Prebuilt URL without redirect_uri (the provider uses its registered one)
await getAuthCode(authUrl, { redirectUri: "http://127.0.0.1:8765/callback" });

Builder form. The default redirect URI is http://127.0.0.1:0/callback: port 0 means the OS assigns a free port (RFC 8252 §7.3), so the URL can only be built after binding. The builder receives the bound redirectUri, a fresh state and the flow's signal. redirect_uri and state are appended when absent and must match when present.

URL form. The library listens on the URL's redirect_uri, or on the redirectUri option when the URL has none. Port 0 is not allowed: the provider must already know the port.

PAR/JAR. Pushed and signed requests carry redirect_uri and state inside the request object, so only the builder form supports them. Put the provided values into the pushed request and return the short URL; pass signal to fetch.

ts
const { code, redirectUri } = await getAuthCode(
  async ({ redirectUri, state, signal }) => {
    const res = await fetch(PAR_ENDPOINT, {
      method: "POST",
      body: new URLSearchParams({
        client_id,
        response_type: "code",
        redirect_uri: redirectUri,
        state,
        code_challenge,
        code_challenge_method: "S256",
      }),
      signal,
    });
    const { request_uri } = await res.json();
    return `${AUTHORIZE}?client_id=${client_id}&request_uri=${encodeURIComponent(request_uri)}`;
  },
);

localhost. Accepted, never generated (RFC 8252 §8.3). A localhost redirect URI listens on 127.0.0.1.

The result's redirectUri is the exact redirect URI of the flow, never re-serialized. When the authorization request carried redirect_uri (always with a builder), send it verbatim in the token request (RFC 6749 §4.1.3). A prebuilt URL without redirect_uri relies on the provider's registered URI; follow the provider's rules for the token request.

State and callback validation ​

Every flow has a state: 32 random bytes, base64url. A prebuilt URL may bring its own.

The listener accepts a callback only when it is unambiguously this flow's:

  • GET on the exact redirect path, with the redirect URI's own query parameters
  • exactly one state, equal to the flow's
  • either code or error (not both, not empty), with no duplicated code, error, error_description, error_uri or iss

A wrong path gets a 404, a wrong method a 405, and an invalid callback a 400; the flow keeps waiting, so a stray or stale request can't end it. The first valid callback wins; later ones get a 400.

Authorization URL validation ​

Every authorization URL is checked before any launcher sees it:

  • https:, or http: on a loopback host (127.0.0.1, [::1], localhost or *.localhost)
  • no fragment or credentials
  • state, redirect_uri, response_type, response_mode, request and request_uri at most once
  • response_type absent or code, response_mode absent or query

Failures throw TypeError: for a prebuilt URL before anything binds, for a builder's URL before launch (the listener binds first).

Launching the browser ​

launch receives the final URL: with redirect_uri and state in place, except a builder's PAR/JAR URL, which carries them in the pushed request. The default opens the system browser; the launcher is bundled and loaded lazily, so flows with a custom launch never load it.

The launcher's fulfillment is ignored: resolving doesn't mean the user finished. A throw or rejection fails the flow with that error.

ts
await getAuthCode(build, { launch: (url) => console.log(`Open ${url}`) }); // headless / SSH
await getAuthCode(build, { launch: (url) => showQrCode(url) });
await getAuthCode(build, { launch: (url) => fetch(url) }); // tests against a mock server

Timeout and cancellation ​

One signal drives every stage: your signal combined with timeout (default 300000 ms, an integer in [1, 2³¹−1]). It covers the builder, the launcher and the wait for the callback.

  • Timeout rejects with a DOMException named TimeoutError.
  • Abort rejects with signal.reason.

The listener closes in every case.

Callback pages ​

The browser gets a neutral page: "Authorization response received. You can close this tab." or "Authorization failed. Return to the application for details." Callback data is never rendered, because error_description and error_uri are attacker-controllable until verified. Show details in your own UI.

successHtml and errorHtml replace the defaults and are served verbatim; there is no templating. Every response carries:

  • Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
  • Cache-Control: no-store
  • Referrer-Policy: no-referrer
  • X-Content-Type-Options: nosniff

The CSP forbids scripts, so custom pages should be HTML and CSS only.

MCP authorization ​

oauth-callback/mcp splits the work with the MCP SDK (@modelcontextprotocol/client):

MCP SDKbrowserAuth()
Protected-resource and AS discoveryOpening the browser
Dynamic Client RegistrationLoopback listener and state
PKCEFlow ownership and timeout
Token exchange and refreshCredential persistence
iss checks, client authenticationconnect() with automatic retries

Key rules:

  • Fixed redirect URI. redirectUri is required and can't use port 0: Dynamic Client Registration registers it.
  • One flow at a time. A provider runs one interactive authorization at a time, from state() until its token exchange settles. Concurrent connect() calls queue and reuse its result. Other attempts that overlap it (e.g. a second request's step-up) fail fast instead of merging: UnauthorizedError on transports connect() created, a plain Error on your own transports, where only the originating transport may complete a flow.
  • Same transport. A flow completes on the transport that received the 401/403, which holds the scope and resource metadata the exchange needs.
  • timeout bounds one interactive authorization, from the browser step through the token exchange. connect() aborts a hung exchange at the deadline; completeAuthorization() can't interrupt your transport's finishAuth(), so give that transport a bounded fetch. Discovery and registration before the browser step aren't covered: for an overall deadline, pass connect(client, { signal: AbortSignal.timeout(ms) }).

connect(client) resolves once the client is connected. For a client it already connected it is a no-op, after completing any pending step-up flow, so it is safe to call again on UnauthorizedError. It never closes a transport it didn't create. For your own transports, use completeAuthorization(transport).

Credential storage ​

A CredentialStore persists one opaque string: load() and save(text). The adapter owns the format ({ version: 1, serverUrl, client?, tokens? }), so a custom store never deals with OAuth records.

  • Default: memory, for the process lifetime.
  • fileStore(path): an absolute path, written atomically, with 0600 permissions on POSIX. No cross-process locking: one file per process.
  • Custom: e.g. the OS keychain.

Use one store per MCP server: tokens are audience-bound (RFC 8707), and a store holding another server's credentials throws. The PKCE verifier, state and discovery state stay in memory. See CredentialStore.

Runtimes ​

One node:http implementation serves Node.js 22+, Deno 2 and Bun 1.2+. The package has zero runtime dependencies. browserAuth() additionally depends on the runtime support of @modelcontextprotocol/client.

Further reading ​