Upstream providers

In local mode, auth.providers lists OAuth providers your tools need to act for the user, like the team chat and the CRM a help desk works with. They're linked while the user signs in: the sign-in page becomes a picker where the user types their email and ticks providers, FrontMCP sends them to each ticked provider in turn, and keeps each provider's tokens on the server. A tool then calls a provider's API as the user with this.orchestration.getToken(id). federatedAuth sets how many providers, and which ones, a user must link before FrontMCP issues a token. The providers don't decide who the user is: that's still the email on the picker. When one provider is both where users sign in and whose API tools call, use remote mode instead.

@FrontMcp({
  auth: {
    mode: "local",
    providers: [{ id, authorizeUrl, tokenUrl, clientId, clientSecret?, scopes?, userInfoEndpoint?, issuer?, jwksUri?, … }],
    federatedAuth?: { stateValidation, minProviders?, requiredProviders? },
  },
})

Reference

auth.providers

Each entry is one provider. Here a help desk links its team chat and its CRM:

main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  http: { port: 3000 },
  auth: {
    mode: "local",
    // FrontMCP's public address: the callback URLs are built from it
    local: { issuer: "https://desk.example.com" },
    providers: [
      {
        id: "chat",
        authorizeUrl: "https://chat.example.com/oauth/authorize",
        tokenUrl: "https://chat.example.com/oauth/token",
        userInfoEndpoint: "https://chat.example.com/userinfo",
        clientId: "help-desk",
        clientSecret: process.env.CHAT_CLIENT_SECRET,
        scopes: ["messages:write"],
      },
      {
        id: "crm",
        authorizeUrl: "https://crm.example.com/authorize",
        tokenUrl: "https://crm.example.com/token",
        userInfoEndpoint: "https://crm.example.com/me",
        clientId: "help-desk",
        clientSecret: process.env.CRM_CLIENT_SECRET,
        scopes: ["customers:read"],
      },
    ],
    // No token until the user has linked the CRM
    federatedAuth: { stateValidation: "strict", requiredProviders: ["crm"] },
  },
})
export default class Server {}

At each provider, register a client whose redirect URI is <local.issuer>/oauth/provider/<id>/callback: https://desk.example.com/oauth/provider/chat/callback at the chat, https://desk.example.com/oauth/provider/crm/callback at the CRM. Set JWT_SECRET too, as for any local-mode server: FrontMCP signs its tokens with it. See more examples below.

Provider options

OptionTypeDefaultDescription
idstringRequiredNames the provider everywhere: in its callback path, /oauth/provider/<id>/callback, on the picker, in federatedAuth and in this.orchestration.getToken(id).
authorizeUrl or authorizationEndpointstringRequiredWhere the user is sent to link the provider. One of the two names is enough.
tokenUrl or tokenEndpointstringRequiredWhere FrontMCP exchanges the provider's code, and renews its token. A provider without one stops the server with a validation error at auth.providers.<n>.tokenEndpoint: tokenEndpoint (or tokenUrl) is required.
clientIdstringRequiredFrontMCP's client id at the provider.
clientSecretstringnoneSent in the form body of the code exchange (client_secret_post). Leave it out for a public client: FrontMCP always uses PKCE.
scopesstring[][]What FrontMCP asks the provider for, as scope on the redirect. With none, the redirect has no scope.
userInfoEndpointstringnoneAsked who the user is, with the provider's access token. Its answer must have a sub. See Where the user's identity comes from.
issuer, additionalIssuersstring, string[]noneThe provider's issuer. With it, an iss on the provider's redirect back must name it, or one of additionalIssuers, and the provider's id_token can be used instead of userInfoEndpoint. Without it, an iss on the redirect is ignored, and so is the id_token.
jwksUristringnoneThe provider's public keys, to check its id_token. Used only with issuer. There's no inline jwks for these providers, unlike remote mode.
namestringThe idAccepted, and shown nowhere: the picker shows the id.

federatedAuth

OptionTypeDefaultDescription
minProvidersnumber1How many providers the user must link. Checked twice: on the providers ticked on the picker, and on the ones linked at the end, after any the user declined at the provider.
requiredProvidersstring[]noneProviders the user must tick, and link. A required provider the user declines ends the sign-in.
stateValidation"strict" | "format""strict"Required by the TypeScript type, and changes nothing: FrontMCP checks the state a provider sends back against the one it sent, either way. With "format", a mismatch also logs a warning saying so.

federatedAuth is read in remote mode too, where there's exactly one provider: there, minProviders above 1, or another id in requiredProviders, fails every sign-in with Sign-in did not complete: a required provider was not linked.. Leave it out of remote mode.

How a sign-in works

The client's side is the same as in local mode: it opens /oauth/authorize, gets a code on its redirect_uri, and exchanges it at /oauth/token. What happens in the user's browser in between changes:

  1. /oauth/authorize answers with the provider picker instead of the sign-in page, and sets the sign-in cookie. The picker has a check box per provider, labelled with its id, an Email field, and two buttons: Continue, and Skip All, which submits with nothing ticked. It names the client by its client_id. ui.federated replaces it with a page of your own.
  2. The form posts to /oauth/callback with federated=true, one providers field per ticked box, and email. FrontMCP checks the cookie, that there's an email (unless requireEmail is false), that every id is one of providers, that at least minProviders are ticked and that every requiredProviders id is. Then it redirects the browser to the first ticked provider's authorizeUrl with response_type=code, client_id, redirect_uri (<local.issuer>/oauth/provider/<id>/callback), a state of federated:…, a PKCE challenge of its own, and scope.
  3. The provider sends the user back to that callback with a code. FrontMCP checks the state, the sign-in cookie, and the iss if the provider has an issuer; exchanges the code at tokenUrl (a form POST with grant_type=authorization_code, code, redirect_uri, client_id, client_secret if set, and its PKCE verifier); finds out who the user is at the provider; and keeps the provider's tokens, encrypted, in tokenStorage. Then it redirects to the next ticked provider, in the order they were ticked.
  4. A user who declines at a provider comes back with error=access_denied: that provider is skipped and the next one starts, unless it's in requiredProviders. Any other error from a provider ends the sign-in with a 400 page.
  5. After the last provider, FrontMCP checks minProviders and requiredProviders again, against the providers actually linked. With consent on, the tool selection page comes now, and posts to /oauth/provider/_consent/callback.
  6. FrontMCP redirects to the client's redirect_uri with code, state and iss, and clears the sign-in cookie.

The first Playground below runs each step with stand-ins for two providers.

What tools see

A token from the picker is a local-mode token: Tokens has its claims. What the providers change:

Value
this.auth.user.subDerived from the email typed on the picker, as in local mode: the same email is always the same user. Never the provider's sub.
this.auth.user.emailThe email typed on the picker.
this.auth.user.nameThe first linked provider's name: the picker has no name field.
this.auth.claims.federated{ enabled: true, selectedProviders, skippedProviders }: the providers linked, and the ones declined at the provider. A provider left unticked is in neither.
this.orchestration.getProviderIds()The providers linked. hasProvider(id) checks one.
this.orchestration.getToken(id)The provider's access token. Throws TokenNotAvailableError (OrchestratedAuthorization: No tokens available for provider "crm") for a provider that isn't linked; tryGetToken(id) returns null instead.
this.orchestration.primaryProviderIdundefined, so pass an id: getToken() without one throws NoProviderIdError (No provider ID specified and no primary provider set).

FrontMCP renews a provider's token with its refresh token, when it sent one, as it does in remote mode: when a tool reads the token less than refresh.skewSeconds before it expires, or after. Calling the provider's API as the user covers renewal and what happens when the client refreshes FrontMCP's token. A provider's token never reaches the model or the client unless a tool returns it.

Caveats

  • The providers don't sign the user in. The user is whoever the email on the picker says, and nothing checks it, as on local mode's built-in sign-in page. A provider only adds a token. Don't decide who may do what from the email alone.
  • Every provider must say who the user is, even though the picker already did: by a userinfo answer with a sub, or an id_token FrontMCP can check. A plain OAuth provider without OpenID Connect, like GitHub, whose user API has id and no sub, can't be linked: the sign-in fails with Could not determine your identity from the provider.
  • Linking happens at sign-in. The picker says skipped providers can be authorized later, but a token stays without the providers the user didn't link: to link one, the user signs in again and ticks it.
  • The callback URL needs local.issuer. Without it, FrontMCP builds http://localhost:<http.port>/oauth/provider/<id>/callback, where http.port defaults to PORT, or 3000, which is wrong anywhere but your own machine.
  • One instance, unless you share storage. Sign-ins in progress and the providers' tokens are kept in tokenStorage, in memory by default: a restart loses them, and a provider that sends the user back to another instance gets Authentication session expired. Use { redis } or { sqlite }.
  • Not with incrementalAuth. A token from the picker has no authorized_apps claim, so with incrementalAuth every tool call answers AUTHORIZATION_REQUIRED, as the first Playground shows.
  • Apps with their own auth make a picker too, without providers, and choosing one of them links nothing. See Apps with their own auth.

Usage

Linking providers at sign-in

server.ts lists two providers. The tests follow one sign-in: the picker, the redirect to each provider and back, the code the client gets, and the tools that use each provider's token:

Open
import { HelpDeskApp } from "./help-desk.app";

// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "local" as const,
    local: { issuer: "https://desk.example.com" },
    providers: [
      {
        id: "chat",
        authorizeUrl: "https://chat.example.com/oauth/authorize",
        tokenUrl: "https://chat.example.com/oauth/token",
        userInfoEndpoint: "https://chat.example.com/userinfo",
        clientId: "help-desk",
        clientSecret: "chat-stand-in-secret", // process.env.CHAT_CLIENT_SECRET in a real server
        scopes: ["messages:write"],
      },
      {
        id: "crm",
        authorizeUrl: "https://crm.example.com/authorize",
        tokenUrl: "https://crm.example.com/token",
        userInfoEndpoint: "https://crm.example.com/me",
        clientId: "help-desk",
        scopes: ["customers:read"],
      },
    ],
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The providers never see the MCP client: each one sees help-desk, FrontMCP's own client, coming back to FrontMCP's callback, so clients register with FrontMCP and not with the providers. The CRM's token request has no client_secret because its entry has none. post_to_team uses getToken(), which throws when the chat isn't linked, and find_customer uses tryGetToken() and tells the model what to do. Prefer the second: the throw reaches the model as a TOOL_EXECUTION_ERROR about a provider it has never heard of.

Requiring providers

By default the user must link at least one provider. minProviders raises the count, and requiredProviders names providers the user can't skip, ticked on the picker and linked at the provider:

Open
import { HelpDeskApp } from "./help-desk.app";

const providers = [
  { id: "chat", authorizeUrl: "https://chat.example.com/oauth/authorize", tokenUrl: "https://chat.example.com/oauth/token", userInfoEndpoint: "https://chat.example.com/userinfo", clientId: "help-desk" },
  { id: "crm", authorizeUrl: "https://crm.example.com/authorize", tokenUrl: "https://crm.example.com/token", userInfoEndpoint: "https://crm.example.com/me", clientId: "help-desk" },
];

export const server = (federatedAuth?: { stateValidation: "strict"; minProviders?: number; requiredProviders?: string[] }) => ({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: { mode: "local" as const, local: { issuer: "https://desk.example.com" }, providers, ...(federatedAuth ? { federatedAuth } : {}) },
});

// The CRM is required; the chat is optional
export const config = server({ stateValidation: "strict", requiredProviders: ["crm"] });

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The picker's checks run before any provider is asked, so a user who ticked too few gets the error page right away. The ones at the end count what was linked: a user can tick the CRM and still decline it at the CRM. A declined optional provider is in federated.skippedProviders, and its tools get no token, as in the first Playground.

Where the user's identity comes from

The picker decides who the user is, but FrontMCP still asks each provider who the user is there, and won't link a provider that can't say. It asks userInfoEndpoint with the provider's token, and needs a sub in the answer. With issuer and jwksUri set, it reads the provider's id_token instead, when the token response has one that checks out:

Open
import { HelpDeskApp } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "local" as const,
    local: { issuer: "https://desk.example.com" },
    providers: [
      {
        id: "chat",
        authorizeUrl: "https://chat.example.com/oauth/authorize",
        tokenUrl: "https://chat.example.com/oauth/token",
        userInfoEndpoint: "https://chat.example.com/userinfo",
        clientId: "help-desk",
        // An iss on the chat's redirect back must name it
        issuer: "https://chat.example.com",
      },
    ],
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The id_token path was checked on Node against a provider on localhost that signs one (FrontMCP fetches jwksUri with its own HTTP client, which a stand-in fetch can't answer): with issuer and jwksUri, FrontMCP fetched the keys and asked no userinfo endpoint, and the provider was linked without one. Without issuer, the same id_token was ignored, and with no userInfoEndpoint the sign-in failed with the error above. The token must be signed with one of the provider's keys, name issuer (or one of additionalIssuers) as its iss and your clientId in its aud, and not be expired. Either way, the provider's sub, email and name only fill what the picker didn't give: the user's sub and email stay the picker's.

Apps with their own auth

Without providers, a local-mode server whose apps have their own auth shows the same picker, listing the server itself and each such app. It looks like a way to sign in to each app's provider, but in FrontMCP 1.9.4 no app's provider is asked: ticking an app goes straight to the client with a code:

Open
import { App } from "@frontmcp/sdk";
import { ListInvoices, WhoAmI } from "./tools";

@App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] })
class HelpDeskApp {}

// Its own auth: tokens from the billing team's identity provider
@App({ id: "billing", name: "Billing", tools: [ListInvoices], auth: { mode: "transparent", provider: "https://billing-sso.example.com" } })
class BillingApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp, BillingApp],
  auth: { mode: "local" as const, local: { issuer: "https://desk.example.com" } },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The token is an ordinary local-mode token, with no federated claim, and the billing app's tools run for it on the shared endpoint: an app's own auth is checked only at the app's own endpoint (standalone or splitByApp), as Auth for one app explains. To give each app its own sign-in, give it its own endpoint. For one provider per tool on one endpoint, list the providers in auth.providers, and keep the apps without auth. In remote mode, apps with their own auth don't get a picker: every sign-in goes to the server's provider.


Troubleshooting

At least 1 provider must be linked

The picker was submitted with fewer providers ticked than federatedAuth.minProviders, 1 by default, often with Skip All. The number in the message is the minimum. The user goes back and ticks more; to let users sign in without linking anything, local mode needs no providers at all. See Requiring providers.

Required provider(s) not linked: crm

A provider in federatedAuth.requiredProviders wasn't ticked on the picker. Nothing marks required providers on the built-in picker: tell users which ones they need, or replace the picker with one that does.

Sign-in did not complete: a required provider was not linked.

After the last provider, the providers actually linked don't meet federatedAuth: the user declined a required provider at the provider, or declined enough optional ones to fall under minProviders. It's also the message when they declined the only one they ticked, required or not, and in remote mode when federatedAuth asks for more than its one provider. The sign-in is over: the user starts again from the client.

Could not determine your identity from the provider

FrontMCP exchanged the provider's code but couldn't find a sub for the user there: the provider's id_token wasn't usable (no issuer or jwksUri, or it didn't check out), and userInfoEndpoint is missing, failed, or answered without a sub. Plain OAuth APIs without OpenID Connect, like GitHub's, can't be linked. See Where the user's identity comes from.

Invalid provider selection

The picker's form named a provider id that isn't in providers. The built-in picker only offers known ids; a picker of your own, or a hand-built request, sent another.

Email is required

The picker's form had no email. The built-in picker requires one. Set requireEmail: false only when the server has one user: every sign-in without an email is then the same user.

Authorization response came from an unexpected issuer.

The provider's redirect back carried an iss that isn't the provider's issuer or one of its additionalIssuers, so FrontMCP refused to exchange the code. If the provider names itself in more than one way, list the others in additionalIssuers.

Authentication session expired. Please try again.

The provider sent the user back to a sign-in FrontMCP doesn't have: it started before a restart, or on another instance, with the default in-memory tokenStorage, or it's already finished. Use { redis } or { sqlite } for tokenStorage with more than one instance. Invalid state parameter. Please restart authentication. means the state named a sign-in that exists, but not the value FrontMCP sent for this provider.

This sign-in was started in another browser, or at another address. Start it again from the app.

The provider's redirect back reached the callback without the sign-in cookie: the user finished in another browser, or the client opened /oauth/authorize at another host than local.issuer's. Set local.issuer and FRONTMCP_PUBLIC_URL to the same URL. Remote and proxied auth has more.

The provider says the redirect URI is invalid

The provider has another redirect URI registered for clientId than the one FrontMCP sent, <local.issuer>/oauth/provider/<id>/callback. Read it from the redirect to the provider and register exactly that. Usually local.issuer is missing, so FrontMCP sent http://localhost:3000/…, or the provider's id changed.

tokenEndpoint (or tokenUrl) is required

A provider has no token endpoint, so the server doesn't start: createFetchHandler() and the Node server reject with a validation error at auth.providers.<n>.tokenEndpoint. A provider without an authorization endpoint fails the same way, with authorizationEndpoint (or authorizeUrl) is required. Add tokenUrl or tokenEndpoint, and authorizeUrl or authorizationEndpoint.

No tokens available for provider "crm"

this.orchestration.getToken("crm") threw TokenNotAvailableError: the user didn't link the CRM, declined it, or its token expired without a refresh token to renew it. Check the id against providers, and use tryGetToken() to tell the model what the user should do, as find_customer does in the first Playground. No provider ID specified and no primary provider set means the call had no id.

Property 'stateValidation' is missing in type

TypeScript requires stateValidation in federatedAuth. Add stateValidation: "strict": it changes nothing at run time.

Ticking an app on the picker didn't sign the user in to its provider

Apps with their own auth are listed on the picker, but in FrontMCP 1.9.4 ticking one links nothing. See Apps with their own auth.