Skip to main content
Declare auth: oauth() on an upstream MCP server and Fentaris performs the OAuth 2.1 client side for you: metadata discovery, dynamic client registration, PKCE, token exchange, refresh, and account-scoped token storage. Legacy per-user authorizations remain supported by the SDK and require explicit migration for named-account CLI use.

Quick Start

Runtime selects the account independently of the downstream identity: { id: "assistant", upstreamAccounts: { linear: "work" } }. A single configured account may be inferred; multiple accounts require an explicit choice.

Four Ways to Declare It

oauth()

Zero configuration. Fentaris discovers the protected resource and authorization server, registers a client dynamically (RFC 7591), uses PKCE with S256, and stores one authorization per named account. Servers without named accounts retain legacy per-user SDK behavior.

oauth({ clientId, clientSecret, scopes })

Pre-registered client. Fentaris skips dynamic registration and authenticates to the token endpoint with the strongest method the authorization server supports. clientSecret accepts a literal string or credential("..."); a named-account credential reference resolves through the project vault or defaults.credentials. Legacy scoped OAuth requires an application default declaration; group/user credentials are never implicitly used as client secrets.

oauth.clientCredentials({ clientId, clientSecret, scopes })

Machine-to-machine access. No redirect and no human: Fentaris fetches a token directly and every caller shares it.

oauth({ provider })

Escape hatch. The factory receives { server, user, session, store, redirectUrl } and returns an MCP SDK OAuthClientProvider that Fentaris binds to that user’s transport.

Options

tokens

"per-user" (default for the authorization-code grant) isolates one authorization per Fentaris user. "shared" reuses the first completed authorization for every caller. Unauthenticated callers always collapse to the shared session, so a personal single-user proxy works with no identity configuration.

registration

Inferred from your declaration: "dynamic" with no clientId, "preregistered" with one, "metadata-url" when clientMetadataUrl is set. Set it explicitly to force one mode.

oauth.publicUrl

Externally reachable base URL of the proxy. Required behind a reverse proxy or any non-loopback deployment, because the authorization server redirects the browser to <publicUrl><callbackPath>. When unset, Fentaris derives the callback from the bound listener.

oauth.callbackPath

Path of the hosted redirect route. Defaults to /_fentaris/oauth/callback. Validation rejects a path that overlaps the MCP path or the Edge control-plane base path.

oauth.store

Any object implementing get, set, delete, and list. Defaults to the encrypted local store when a key is available, otherwise an in-memory store.

oauth.consentTimeoutMs

How long a tool call waits for the human to finish consent. Defaults to 90000, below the usual MCP client request timeout. On timeout the call returns a “pending, retry” result instead of hanging.

oauth.agentTools

Set to false to skip registering the built-in fentaris__auth_status and fentaris__auth_login tools. When group policies are enabled, grant access to these tools through the reserved fentaris namespace:
  1. A tool call needs an authorization that does not exist yet.
  2. If the connected MCP client supports URL-mode elicitation, Fentaris sends it the authorization URL and waits.
  3. The human signs in; the authorization server redirects to the Fentaris callback; Fentaris exchanges the code and notifies the client.
  4. Fentaris retries the original tool call once and returns its real result.
Clients that cannot elicit a URL get a structured error result instead:
tools/list never contacts an authorization server. An upstream that still needs a login contributes no tools instead of failing the whole listing, and the built-in fentaris__auth_* tools tell the agent why.

Token Store

Named-account tokens, client registrations, and discovery metadata are private lifecycle records in the shared encrypted ProjectVault, isolated by server and account. They are omitted from ordinary secret listings and never exposed by MCP JSON output. Legacy scoped records remain in <authDir>/oauth-tokens.enc.json and are never automatically reassigned. The runtime reads the same vault references as the CLI, so refreshes persist automatically. A headless transport can refresh stored authorization without a callback URL; a callback is required only for a new authorization-code flow. Named accounts use ProjectVault with macOS Keychain, an application credential-store adapter, or an explicit FENTARIS_VAULT_KEY (FENTARIS_VAULT_UNLOCK_KEY is an MCP-only alternative). MCP and secrets never write unlock keys to .env. Legacy SDK sessions use FENTARIS_AUTH_KEY or an explicit oauth.store; without either, those legacy sessions use an ephemeral store and report a configuration warning.

CLI

Connect opens a loopback browser flow or prints the URL to stderr when explicitly requested. JSON and noninteractive flows do not open a browser; authorization-code flows require --print-url. OAuth --timeout is in milliseconds (300000 by default). Existing authorization is preserved until --reauth succeeds; cancellation closes the listener and leaves the connection unchanged. Disconnect clears only the selected account, attempts remote revocation if the provider advertises an endpoint, and reports its outcome. Shared credential values are preserved. To migrate a legacy session, explicitly run fentaris mcp auth migrate linear --account work --from-session user:alice; the source is retained for rollback. Legacy shared and user: namespaces remain distinct from account: sessions.

Network Guardrails

Every authorization-server request — discovery, registration, token, revocation — passes the same guardrails as the upstream MCP URL. A private, loopback, or link-local authorization server is refused unless the transport allows it:

Troubleshooting

The browser lands on a “not found” page after signing in

The authorization server redirected to a callback the proxy does not host. Set oauth.publicUrl to the externally reachable base URL and confirm oauth.callbackPath.

The tool call returns “Authorization is still pending”

Consent did not complete within consentTimeoutMs. Sign in, then call the tool again; the stored tokens are used immediately.

FENTARIS_CONFIG_OAUTH_TRANSPORT_UNSUPPORTED

oauth() only works on the native streamableHttp() and sse() upstream transports. A stdio server cannot carry an OAuth authorization.

FENTARIS_CONFIG_OAUTH_CLIENT_SECRET_UNRESOLVED

The credential(...) reference used for clientSecret is not declared in app defaults, groups, or users. Declare it and store the value with fentaris secrets set.

The upstream keeps asking for a new login

The refresh token was rejected or the authorization server invalidated the registered client. Run fentaris mcp auth connect <MCP> --account <ACCOUNT> --reauth to authorize that account again.

Authenticate Inbound MCP Clients

Configure an external authorization server as the issuer. Fentaris validates its access tokens and serves RFC 9728 metadata; clients discover the issuer, sign in there, and send Authorization: Bearer <token> to Fentaris.

Issuer, resource and scopes

issuer must exactly match the token’s iss. resource must appear in aud (a string or array). Fentaris requires a finite exp, checks nbf when present, and allows 60 seconds of clock skew. Required scopes must all appear in the space-separated scope claim or scp array. Granted scopes are available in ctx.identity.metadata.scopes. Set the externally visible resource behind a reverse proxy, including any public path prefix. If omitted, it defaults to oauth.publicUrl plus the MCP path, or the actual loopback listener URL in development. Non-loopback HTTP and URLs with credentials, query strings or fragments are rejected. OAuth identity requires authentication by default; an explicit { strategy, required: false } makes identity optional.

JWT signing keys

By default, Fentaris discovers jwks_uri through authorization-server metadata (RFC 8414), falling back to OpenID Connect discovery. It verifies RS256, PS256, ES256 and EdDSA (Ed25519) signatures with Node crypto. HS256 and encrypted JWTs are unsupported. Use jwks: { url: "https://auth.example.com/keys", cacheTtlMs: 300000 } for an explicit endpoint. Keys are cached for five minutes by default. An unknown kid refreshes the JWKS once; cached keys remain usable during an endpoint outage, and requests without an available signing key fail closed.

Discoverable login entrypoints

Fentaris remains an OAuth resource server: it does not implement authorization, token, or registration endpoints. To give a person an obvious place to begin, configure the existing issuer-owned sign-in page explicitly:
loginUrl is the minimal contract: an absolute HTTPS URL (loopback HTTP is accepted for local fixtures), with no credentials, query, or fragment. Fentaris then owns the stable public GET /_fentaris/login route and redirects it to that configured page. It never guesses an issuer login URL. Protected-resource metadata includes fentaris_login_uri, and an unauthenticated MCP 401 keeps the standard WWW-Authenticate challenge while adding the configured public login URI in Link, for example Link: <https://proxy.example.com/_fentaris/login>; rel="login". After downstream authentication, GET /_fentaris/auth returns only upstream servers/accounts visible to that caller, including servers authorized only for resource or prompt operations. Effective permissions include denies from overlapping groups. Reads are side-effect free. An OAuth entry that needs consent contains a Fentaris-owned URL such as https://proxy.example.com/_fentaris/auth/login?server=calendar&account=work. The user (or client) explicitly POSTs that URL; Fentaris then starts the existing PKCE/state flow and responds with a 303 to the provider. The provider returns to /_fentaris/oauth/callback; after success, retry the original MCP operation. Anonymous GET and POST requests receive 401 with the normal Bearer challenge and configured login link; they cannot enumerate accounts or start another user’s flow. Bearer, API-key/header, client-credential, and managed accounts return setup-required guidance rather than a fake browser URL. Explicitly posting a visible noninteractive account also returns that guidance without starting OAuth. Configure their credential references/secrets or use the operator-managed connection workflow. Network/provider failures from explicit OAuth initiation return login_unavailable; they are distinct from the catalog’s requires-login state. Responses never contain stored credentials. These built-in entrypoints are installed by app.start() on the HTTP exposure. RFC 9728 metadata and the downstream login discovery route are also available on the built-in SSE exposure; upstream browser entrypoints require the shared HTTP route registry. A custom exposure must explicitly mount equivalent routes. Keep oauth.publicUrl set behind a reverse proxy so callback and returned links use the external origin and any configured path prefix.

Metadata and bearer challenges

HTTP and SSE exposures serve public JSON at /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource<mcpPath> (the SSE path for SSE exposure). The document contains resource, authorization_servers, bearer_methods_supported: ["header"], and configured scopes_supported. Every OAuth 401 includes WWW-Authenticate: Bearer resource_metadata="...". Failed verification adds error="invalid_token"; missing required scopes add error="insufficient_scope" and scope. Sessions stay bound to the authenticated user and strategy; presenting another user’s token on an existing session returns 401. Fentaris does not provide inbound authorization, token or client-registration endpoints. Configure those on your issuer.

Opaque tokens and introspection

Provide verify to return trusted claims from your issuer’s authenticated introspection endpoint. Fentaris still checks issuer, audience, expiry and scopes. Configure either verify or jwks.

User mapping and API-key fallback

The default user id is claims.sub. mapUser may synchronously or asynchronously map trusted claims to a declared Fentaris user. When groups are configured, mapped users must belong to one of them. To keep API keys alongside OAuth, use an ordered strategy array. The first non-null identity wins; the first strategy exposing a challenge supplies OAuth discovery.

Verify OAuth Locally

The repository includes an opt-in mcpjam SDK suite and a campaign against packed packages:
The campaign covers upstream DCR/elicitation, CLI login, client credentials, inbound DCR and pre-registered headless conformance, login with tool calls, and API-key fallback. Each attempt retains a requirement-mapped REPORT.md, command logs and tarball SHA-256 identities. Any failed or blocked scenario returns a non-zero exit code. See scripts/oauth-verification/README.md in the repository for candidate and attempt options.