Skip to content

OAuth2 Internals

Chenglei Yuan edited this page Jun 4, 2026 · 1 revision

OAuth2 Internals

A deep dive into the oauth2/ layer: the grant abstraction, token management, discovery, the interactive callback server, and the encrypted token cache. For user-facing behavior see OAuth2 Grants and Tokens and Discovery.

The Grant interface

All grants implement a small interface (oauth2/grants/types.ts):

interface Grant {
  readonly name: string;
  fetchToken(): Promise<TokenResponse>;
  supportsRenewal(): boolean;
}

fetchToken() returns a normalized token (access token, expiresIn, tokenType, optional refreshToken). supportsRenewal() tells the TokenManager whether silent renewal is possible.

TokenManager

oauth2/tokenManager.ts is the single token authority:

  • Caches the current access token plus computed expiresAt.
  • getToken() returns the cached token unless it is missing or within the skew window (refreshSkewSeconds), in which case it calls grant.fetchToken().
  • In-flight de-duplication: concurrent getToken() calls during a refresh share one promise (this.inflight) instead of each hitting the token endpoint.
  • invalidate() clears the cache so the next getToken() forces a refresh — this is what the bridge calls on a 401.

Grant factory

oauth2/factory.ts builds the configured grant:

  • A tokenUrl is mandatory — buildGrant throws if none is configured or discovered.
  • client_credentialsClientCredentialsGrant.
  • authorization_code → constructs a TokenCache, loads any cached refresh token (an explicit cfg.refreshToken wins over the cache), and wires onRefreshTokenUpdated to persist rotated refresh tokens back to the cache.
  • The factory accepts either an OAuthConfig or a { cfg, log } pair.

Grants

ClientCredentialsGrant

POSTs grant_type=client_credentials with optional scope, audience, and extraParams. No refresh token; renewal re-runs the grant.

AuthorizationCodeGrant

Lazily resolves a token via three modes (in order):

  1. Pre-existing refresh token → immediately delegate to a RefreshTokenGrant.
  2. Pre-supplied one-shot code (+ PKCE verifier) → exchange exactly once.
  3. Interactive flow → run the browser login (below).

After a successful code exchange, if the response includes a refresh token it fires onRefreshTokenUpdated and builds a RefreshTokenGrant delegate for all future renewals. Reusing a code with no refresh token available throws.

RefreshTokenGrant

POSTs grant_type=refresh_token. If the response returns a new refresh token (rotation), it updates its in-memory value and fires onRefreshTokenUpdated so the cache is rewritten.

Token HTTP helpers (oauth2/http.ts)

  • undiciHttpClient.postForm(url, body, headers)application/x-www-form-urlencoded request, Accept: application/json, 30 s timeouts.
  • parseTokenResponse(status, bodyText) — throws on non-2xx, non-JSON, or missing access_token; defaults expires_in to 3600 and token_type to Bearer.
  • applyClientAuth(body, headers, opts) — for authStyle: 'header', sets HTTP Basic auth (and still includes client_id in the body); otherwise puts client_id/client_secret in the body.

Discovery internals (oauth2/discovery.ts)

  • Stage 1: fetch /.well-known/oauth-protected-resource (RFC 9728), read authorization_servers[0].
  • Stage 2: fetch RFC 8414 (/.well-known/oauth-authorization-server) or OIDC (/.well-known/openid-configuration) metadata, trying path-permutation candidates for issuers that carry a path.
  • keepSecure() drops any discovered endpoint that is insecure cleartext (warns, doesn't throw).
  • 5-second per-request timeouts; every field of DiscoveryResult is optional.

Interactive callback server (oauth2/interactive.ts)

  • PKCE: 32-byte random verifier → base64url; S256 challenge.
  • Authorize URL: response_type=code, code_challenge_method=S256, state, scope, redirect URI, plus any extraParams. Printed to stderr.
  • Callback listener: binds callbackHost:callbackPort, serves /callback. isAllowedHost() rejects non-loopback Host headers and port mismatches (DNS-rebinding defense). The state value is validated (CSRF).
  • Browser open: win32 uses rundll32 url.dll,FileProtocolHandler (avoids cmd's & mangling in URLs); darwin uses open; otherwise xdg-open. Spawn errors are swallowed (you can still open the printed URL manually).
  • Warns if callbackHost is non-loopback.

Encrypted token cache (oauth2/tokenCache.ts)

  • Cipher: AES-256-GCM. File layout: iv(12) ‖ tag(16) ‖ ciphertext.
  • Key: random 32-byte key.bin stored alongside the cache with mode 0600.
  • File name: <sha256(clientId|tokenUrl)[:16]>.json.enc.
  • Atomic writes: temp file + rename.
  • Default dir: %APPDATA%\mcp-oauth2-proxy\ (Windows), ~/Library/Application Support/mcp-oauth2-proxy/ (macOS), ${XDG_CONFIG_HOME:-~/.config}/mcp-oauth2-proxy/ (Linux). Override with OAUTH2_TOKEN_CACHE_DIR.
  • Load failures are ignored with a warning (fall back to interactive login).

See Security for the threat model around the cache and callback listener.

Clone this wiki locally