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:
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
| Option | Type | Default | Description |
|---|---|---|---|
id | string | Required | Names the provider everywhere: in its callback path, /oauth/provider/<id>/callback, on the picker, in federatedAuth and in this.orchestration.getToken(id). |
authorizeUrl or authorizationEndpoint | string | Required | Where the user is sent to link the provider. One of the two names is enough. |
tokenUrl or tokenEndpoint | string | Required | Where 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. |
clientId | string | Required | FrontMCP's client id at the provider. |
clientSecret | string | none | Sent in the form body of the code exchange (client_secret_post). Leave it out for a public client: FrontMCP always uses PKCE. |
scopes | string[] | [] | What FrontMCP asks the provider for, as scope on the redirect. With none, the redirect has no scope. |
userInfoEndpoint | string | none | Asked 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, additionalIssuers | string, string[] | none | The 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. |
jwksUri | string | none | The provider's public keys, to check its id_token. Used only with issuer. There's no inline jwks for these providers, unlike remote mode. |
name | string | The id | Accepted, and shown nowhere: the picker shows the id. |
federatedAuth
| Option | Type | Default | Description |
|---|---|---|---|
minProviders | number | 1 | How 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. |
requiredProviders | string[] | none | Providers 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:
/oauth/authorizeanswers 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 itsclient_id.ui.federatedreplaces it with a page of your own.- The form posts to
/oauth/callbackwithfederated=true, oneprovidersfield per ticked box, andemail. FrontMCP checks the cookie, that there's an email (unlessrequireEmailisfalse), that every id is one ofproviders, that at leastminProvidersare ticked and that everyrequiredProvidersid is. Then it redirects the browser to the first ticked provider'sauthorizeUrlwithresponse_type=code,client_id,redirect_uri(<local.issuer>/oauth/provider/<id>/callback), astateoffederated:…, a PKCE challenge of its own, andscope. - The provider sends the user back to that callback with a
code. FrontMCP checks thestate, the sign-in cookie, and theissif the provider has anissuer; exchanges the code attokenUrl(a formPOSTwithgrant_type=authorization_code,code,redirect_uri,client_id,client_secretif set, and its PKCE verifier); finds out who the user is at the provider; and keeps the provider's tokens, encrypted, intokenStorage. Then it redirects to the next ticked provider, in the order they were ticked. - 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 inrequiredProviders. Any other error from a provider ends the sign-in with a400page. - After the last provider, FrontMCP checks
minProvidersandrequiredProvidersagain, against the providers actually linked. Withconsenton, the tool selection page comes now, and posts to/oauth/provider/_consent/callback. - FrontMCP redirects to the client's
redirect_uriwithcode,stateandiss, 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.sub | Derived 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.email | The email typed on the picker. |
this.auth.user.name | The 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.primaryProviderId | undefined, 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 anid_tokenFrontMCP can check. A plain OAuth provider without OpenID Connect, like GitHub, whose user API hasidand nosub, can't be linked: the sign-in fails withCould 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 buildshttp://localhost:<http.port>/oauth/provider/<id>/callback, wherehttp.portdefaults toPORT, or3000, 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 getsAuthentication session expired. Use{ redis }or{ sqlite }. - Not with
incrementalAuth. A token from the picker has noauthorized_appsclaim, so withincrementalAuthevery tool call answersAUTHORIZATION_REQUIRED, as the first Playground shows. - Apps with their own
authmake a picker too, withoutproviders, and choosing one of them links nothing. See Apps with their ownauth.
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:
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:
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:
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:
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.