# Upstream providers

> Linking several OAuth providers in one local-mode sign-in: auth.providers, the provider picker, federatedAuth, the callback each provider returns to, and reading each provider's token in a tool.

Source: https://frontmcp.dev/reference/auth/upstream-providers

In [local mode](https://frontmcp.dev/reference/auth/local), `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](https://frontmcp.dev/reference/auth/remote) instead.

```ts
@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:

```ts 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.](#usage)

#### 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)`](#what-tools-see). |
| `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`](#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](#where-the-users-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](https://frontmcp.dev/reference/auth/remote) 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.`](#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](https://frontmcp.dev/reference/auth/local#the-flow-a-client-follows): 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](https://frontmcp.dev/reference/auth/local#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`](https://frontmcp.dev/reference/auth/login-ui#authui-react-pages) 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`](https://frontmcp.dev/reference/auth/local#options) 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`](https://frontmcp.dev/reference/auth/login-ui#the-consent-screen) 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](#linking-providers-at-sign-in) runs each step with stand-ins for two providers.

#### What tools see

A token from the picker is a local-mode token: [Tokens](https://frontmcp.dev/reference/auth/local#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`](https://frontmcp.dev/reference/auth/local#options) before it expires, or after. [Calling the provider's API as the user](https://frontmcp.dev/reference/auth/remote#calling-the-providers-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](https://frontmcp.dev/reference/auth/local#options). 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`](#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`](https://frontmcp.dev/reference/auth/local#storage), in memory by default: a restart loses them, and a provider that sends the user back to another instance gets [`Authentication session expired`](#authentication-session-expired-please-try-again). Use `{ redis }` or `{ sqlite }`.
- **Not with `incrementalAuth`.** A token from the picker has no `authorized_apps` claim, so with [`incrementalAuth`](https://frontmcp.dev/reference/auth/progressive) every tool call answers `AUTHORIZATION_REQUIRED`, as [the first Playground](#linking-providers-at-sign-in) 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`](#apps-with-their-own-auth).

---

## Usage

> **Note**
The Playgrounds on this page stand the two providers at `https://chat.example.com` and `https://crm.example.com`. `providers.example.ts` stands in for both: it replaces `fetch` during a test and answers FrontMCP's requests to their token and userinfo endpoints, and the APIs the tools call. `sign-in.ts` is an MCP client and the user's browser, with its cookies; `returnFrom()` plays the provider sending the user back. Each Playground's own server is public, so its Call tab works; the tests start the local-mode server from `server.ts` with [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler).

### 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:

```ts server.ts active
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"],
      },
    ],
  },
};
```

```ts help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "find_customer", description: "Find the customer behind a ticket, in the CRM", inputSchema: { ticket: z.string() } })
export class FindCustomer extends ToolContext {
  async execute({ ticket }: { ticket: string }) {
    const token = await this.orchestration.tryGetToken("crm");
    if (!token) {
      this.fail(new PublicMcpError("The user hasn't linked the CRM. Ask them to sign in again and tick crm.", "LINK_CRM"));
    }
    const res = await this.fetch(`https://crm.example.com/api/customers?ticket=${ticket}`, { headers: { authorization: `Bearer ${token}` } });
    return await res.json();
  }
}

@Tool({ name: "post_to_team", description: "Post a message to the support team's chat channel", inputSchema: { text: z.string() } })
export class PostToTeam extends ToolContext {
  async execute({ text }: { text: string }) {
    const token = await this.orchestration.getToken("chat"); // throws when the chat isn't linked
    const res = await this.fetch("https://chat.example.com/api/messages", {
      method: "POST",
      headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
      body: JSON.stringify({ channel: "support", text }),
    });
    return { posted: res.ok };
  }
}

@Tool({ name: "whoami", description: "Say who the server thinks is calling, and which providers they linked", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    const { user, claims } = this.auth;
    return { sub: user.sub, email: user.email ?? null, name: user.name ?? null, linked: this.orchestration.getProviderIds(), federated: claims.federated ?? null };
  }
}

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

```ts sign-in.test.ts
import { test, expect } from "@frontmcp/testing";
import { standInProviders } from "./providers.example";
import { Client, REDIRECT_URI } from "./sign-in";
import { config } from "./server";

test("the sign-in page is a provider picker", async () => {
  const client = await Client.for(config);
  const { status, page } = await client.openPicker();
  expect(status).toBe(200);
  expect(page).toContain('<form method="POST" action="/oauth/callback" id="federated-form">');
  expect(page).toContain('<input type="hidden" name="federated" value="true">');
  expect(page).toMatch(/<input type="checkbox" name="providers" value="chat"/);
  expect(page).toMatch(/<input type="checkbox" name="providers" value="crm"/);
  expect(page).toContain('name="email" required');
  expect([...client.cookies.keys()]).toEqual([expect.stringMatching(/^__Host-frontmcp_signin_\w+$/)]);
});

test("Continue sends the user to each ticked provider in turn", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for(config);
    const { pendingAuthId } = await client.openPicker();
    const toChat = await client.choose(pendingAuthId, ["chat", "crm"]);
    expect(toChat.status).toBe(302);
    const atChat = new URL(toChat.location!);
    expect(atChat.origin + atChat.pathname).toBe("https://chat.example.com/oauth/authorize");
    expect(Object.fromEntries(atChat.searchParams)).toEqual({
      response_type: "code",
      client_id: "help-desk",
      redirect_uri: "https://desk.example.com/oauth/provider/chat/callback",
      state: expect.stringMatching(/^federated:/),
      code_challenge: expect.any(String),
      code_challenge_method: "S256",
      scope: "messages:write",
    });

    // The chat sends the user back; FrontMCP exchanges its code, asks who the user is, and moves on to the CRM
    const toCrm = await client.returnFrom(toChat.location!);
    expect(toCrm.location).toMatch(/^https:\/\/crm\.example\.com\/authorize\?.*redirect_uri=https%3A%2F%2Fdesk\.example\.com%2Foauth%2Fprovider%2Fcrm%2Fcallback/);
    expect(providers.requests.slice(0, 2)).toEqual([
      {
        method: "POST",
        url: "https://chat.example.com/oauth/token",
        form: {
          grant_type: "authorization_code",
          code: "code-from-the-provider",
          redirect_uri: "https://desk.example.com/oauth/provider/chat/callback",
          client_id: "help-desk",
          client_secret: "chat-stand-in-secret",
          code_verifier: expect.any(String),
        },
      },
      { method: "GET", url: "https://chat.example.com/userinfo", token: "chat-token-1" },
    ]);

    // After the last provider, the client gets its code, and the cookie is cleared
    const back = await client.returnFrom(toCrm.location!);
    expect(back.location).toMatch(/^http:\/\/127\.0\.0\.1:33418\/callback\?code=\w+&state=client-state&iss=https%3A%2F%2Fdesk\.example\.com$/);
    expect(client.cookies.size).toBe(0);
    const tokens = await client.token({ grant_type: "authorization_code", code: back.code!, redirect_uri: REDIRECT_URI, client_id: client.clientId, code_verifier: client.verifier });
    expect(tokens).toMatchObject({ token_type: "Bearer", expires_in: 3600, refresh_token: expect.any(String) });
  } finally {
    providers.stop();
  }
});

test("tools call each provider's API with its token", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn(["chat", "crm"]);
    expect((await client.callTool(access_token, "find_customer", { ticket: "T-1" })).structuredContent).toEqual({ customer: "Acme GmbH", plan: "enterprise" });
    expect((await client.callTool(access_token, "post_to_team", { text: "T-1 is waiting on Acme" })).structuredContent).toEqual({ posted: true });
    expect(providers.requests.slice(-2)).toEqual([
      { method: "GET", url: "https://crm.example.com/api/customers", token: "crm-token-2" },
      { method: "POST", url: "https://chat.example.com/api/messages", token: "chat-token-1" },
    ]);
  } finally {
    providers.stop();
  }
});

test("the user is the email on the picker; the first provider gives the name", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn(["chat", "crm"], "nour@example.com");
    expect((await client.callTool(access_token, "whoami")).structuredContent).toEqual({
      sub: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-/),
      email: "nour@example.com", // not the providers' nour@chat.example.com
      name: "Nour Haddad",
      linked: ["chat", "crm"],
      federated: { enabled: true, selectedProviders: ["chat", "crm"], skippedProviders: [] },
    });
  } finally {
    providers.stop();
  }
});

test("a provider left unticked has no token", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn(["chat"]);
    expect(await client.callTool(access_token, "find_customer", { ticket: "T-1" })).toMatchObject({ isError: true, _meta: { code: "LINK_CRM" } });
    expect((await client.callTool(access_token, "whoami")).structuredContent).toMatchObject({ linked: ["chat"], federated: { selectedProviders: ["chat"], skippedProviders: [] } });
  } finally {
    providers.stop();
  }
});

test("getToken() throws for a provider that isn't linked", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn(["crm"]);
    const result = await client.callTool(access_token, "post_to_team", { text: "T-1 is waiting on Acme" });
    expect(result).toMatchObject({ isError: true, _meta: { code: "TOOL_EXECUTION_ERROR" } });
    expect(result.content[0].text).toContain('No tokens available for provider "chat"');
  } finally {
    providers.stop();
  }
});

test("a provider token close to its expiry is renewed with its refresh token", async () => {
  // expires in 30 seconds, less than refresh.skewSeconds, 60 by default
  const providers = standInProviders({ expiresIn: 30, refreshToken: true });
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn(["chat"]);
    await client.callTool(access_token, "post_to_team", { text: "Renewed?" });
    expect(providers.requests.slice(-2)).toEqual([
      { method: "POST", url: "https://chat.example.com/oauth/token", form: { grant_type: "refresh_token", refresh_token: "chat-refresh-1", client_id: "help-desk", client_secret: "chat-stand-in-secret" } },
      { method: "POST", url: "https://chat.example.com/api/messages", token: "chat-token-2" },
    ]);
  } finally {
    providers.stop();
  }
});

test("with incrementalAuth, a token from the picker reaches no app", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for({ ...config, auth: { ...config.auth, incrementalAuth: { enabled: true } } });
    const { access_token } = await client.signIn(["chat", "crm"]);
    expect(await client.callTool(access_token, "whoami")).toMatchObject({ isError: true, _meta: { code: "AUTHORIZATION_REQUIRED" } });
  } finally {
    providers.stop();
  }
});

test("the provider's callback needs the sign-in cookie, and the state FrontMCP sent", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for(config);
    const toChat = await client.choose((await client.openPicker()).pendingAuthId, ["chat"]);
    const otherState = new URL(toChat.location!);
    otherState.searchParams.set("state", otherState.searchParams.get("state")!.replace(/:[^:]+$/, ":another-nonce"));
    expect((await client.returnFrom(otherState.href)).page).toContain("Invalid state parameter. Please restart authentication.");

    const again = await client.choose((await client.openPicker()).pendingAuthId, ["chat"]);
    client.cookies.clear(); // as if the user came back in another browser
    expect((await client.returnFrom(again.location!)).page).toContain("This sign-in was started in another browser, or at another address. Start it again from the app.");
    expect(providers.requests).toEqual([]);
  } finally {
    providers.stop();
  }
});
```

```ts providers.example.ts
// Stands in for the chat and the CRM, which the Playground can't reach: it answers
// FrontMCP's requests to their token and userinfo endpoints, and the APIs the tools call.
export function standInProviders({ expiresIn = 3600, refreshToken = false, userInfo = true } = {}) {
  const realFetch = globalThis.fetch;
  const requests: { method: string; url: string; form?: Record<string, string>; token?: string }[] = [];
  let issued = 0;
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = new URL(input instanceof Request ? input.url : String(input));
    if (url.host !== "chat.example.com" && url.host !== "crm.example.com") return realFetch(input, init);
    const provider = url.host.split(".")[0];
    const method = init?.method ?? "GET";
    const isTokenEndpoint = method === "POST" && !url.pathname.startsWith("/api/");
    const form = isTokenEndpoint ? Object.fromEntries(new URLSearchParams(String(init?.body))) : undefined;
    const token = new Headers(init?.headers).get("authorization")?.replace(/^Bearer /, "");
    requests.push({ method, url: url.origin + url.pathname, ...(form ? { form } : {}), ...(token ? { token } : {}) });
    if (isTokenEndpoint) {
      issued += 1;
      return Response.json({ access_token: `${provider}-token-${issued}`, token_type: "Bearer", expires_in: expiresIn, ...(refreshToken ? { refresh_token: `${provider}-refresh-${issued}` } : {}) });
    }
    if (!token?.startsWith(`${provider}-token-`)) return new Response("Unauthorized", { status: 401 });
    if (url.pathname === "/userinfo" || url.pathname === "/me") {
      // userInfo: false answers like a plain OAuth API: an id, and no sub
      return Response.json(userInfo ? { sub: `${provider}|nour`, name: "Nour Haddad", email: `nour@${provider}.example.com` } : { id: 4242, login: "nour" });
    }
    if (url.pathname === "/api/customers") return Response.json({ customer: "Acme GmbH", plan: "enterprise" });
    if (url.pathname === "/api/messages") return Response.json({ ok: true }, { status: 201 });
    return new Response("Not found", { status: 404 });
  }) as typeof fetch;
  return { requests, stop: () => void (globalThis.fetch = realFetch) };
}
```

```ts sign-in.ts
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://127.0.0.1:33418/callback";

/** An MCP client, and the user's browser with its cookies, signing in through the provider picker. */
export class Client {
  clientId = "";
  readonly verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // RFC 7636's example
  readonly cookies = new Map<string, string>();
  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new Client((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  async send(url: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(new URL(url, SERVER), { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const [name, value] = [pair.slice(0, pair.indexOf("=")), pair.slice(pair.indexOf("=") + 1)];
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** Registers, opens /oauth/authorize, and returns the page and the pending_auth_id its form carries. */
  async openPicker() {
    if (!this.clientId) {
      const registered = await this.send("/oauth/register", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: "none" }),
      });
      this.clientId = (await registered.json()).client_id;
    }
    const query = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: REDIRECT_URI, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", state: "client-state" });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, location: response.headers.get("location"), page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] ?? "" };
  }

  /** Submits the picker, as the browser does: the ticked providers and the email. */
  async choose(pendingAuthId: string, providers: string[], email = "nour@example.com") {
    const form = new URLSearchParams({ pending_auth_id: pendingAuthId, federated: "true", email });
    for (const id of providers) form.append("providers", id);
    return this.follow(await this.send("/oauth/callback", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form }));
  }

  /** The provider at `atProvider` sends the user back to FrontMCP's callback with these parameters. */
  async returnFrom(atProvider: string, params: Record<string, string> = { code: "code-from-the-provider" }) {
    const at = new URL(atProvider);
    const callback = new URL(at.searchParams.get("redirect_uri")!);
    callback.search = new URLSearchParams({ state: at.searchParams.get("state")!, ...params }).toString();
    return this.follow(await this.send(callback.href));
  }

  private async follow(response: Response) {
    const location = response.headers.get("location");
    const code = location?.startsWith(REDIRECT_URI) ? new URL(location).searchParams.get("code") : null;
    return { status: response.status, location, code, page: await response.text() };
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return response.json();
  }

  /** Every step: the picker, each provider in turn, and the code exchange. */
  async signIn(providers: string[], email = "nour@example.com") {
    let step = await this.choose((await this.openPicker()).pendingAuthId, providers, email);
    while (step.location && !step.code) step = await this.returnFrom(step.location);
    return this.token({ grant_type: "authorization_code", code: step.code!, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  async callTool(accessToken: string, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, authorization: `Bearer ${accessToken}` },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return (await response.json()).result;
  }
}
```

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:

```ts server.ts active
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"] });
```

```ts requiring.test.ts
import { test, expect } from "@frontmcp/testing";
import { standInProviders } from "./providers.example";
import { Client } from "./sign-in";
import { config, server } from "./server";

/** Opens the picker on a new server with this config, and submits it. */
async function submitPicker(cfg: ReturnType<typeof server>, providers: string[], email?: string) {
  const client = await Client.for(cfg);
  return { client, step: await client.choose((await client.openPicker()).pendingAuthId, providers, email) };
}

test("with no requirements, at least one provider must be ticked", async () => {
  const { step } = await submitPicker(server(), []); // Skip All
  expect(step.status).toBe(400);
  expect(step.page).toContain("At least 1 provider must be linked");
});

test("minProviders raises the count", async () => {
  const { step } = await submitPicker(server({ stateValidation: "strict", minProviders: 2 }), ["chat"]);
  expect(step.page).toContain("At least 2 providers must be linked");
});

test("a required provider must be ticked", async () => {
  const { step } = await submitPicker(config, ["chat"]);
  expect(step.status).toBe(400);
  expect(step.page).toContain("Required provider(s) not linked: crm");
});

test("the picker's other checks: known ids and an email", async () => {
  expect((await submitPicker(config, ["crm", "billing"])).step.page).toContain("Invalid provider selection");
  expect((await submitPicker(config, ["crm"], "")).step.page).toContain("Email is required");
});

test("an optional provider the user declines is skipped", async () => {
  const providers = standInProviders();
  try {
    const { client, step: toChat } = await submitPicker(config, ["chat", "crm"]);
    const toCrm = await client.returnFrom(toChat.location!, { error: "access_denied" }); // declined at the chat
    expect(toCrm.location).toMatch(/^https:\/\/crm\.example\.com\/authorize\?/);
    const back = await client.returnFrom(toCrm.location!);
    expect(back.code).toEqual(expect.any(String));
    expect(providers.requests.map((r) => r.url)).toEqual(["https://crm.example.com/token", "https://crm.example.com/me"]);
  } finally {
    providers.stop();
  }
});

test("a required provider the user declines ends the sign-in", async () => {
  const providers = standInProviders();
  try {
    const { client, step: toCrm } = await submitPicker(config, ["crm"]);
    const declined = await client.returnFrom(toCrm.location!, { error: "access_denied" });
    expect(declined).toMatchObject({ status: 400, code: null });
    expect(declined.page).toContain("Sign-in did not complete: a required provider was not linked.");
  } finally {
    providers.stop();
  }
});

test("any other error from a provider ends the sign-in", async () => {
  const providers = standInProviders();
  try {
    const { client, step: toChat } = await submitPicker(server(), ["chat", "crm"]);
    const failed = await client.returnFrom(toChat.location!, { error: "server_error", error_description: "Chat is down for maintenance" });
    expect(failed.status).toBe(400);
    expect(failed.page).toContain("Authentication provider error: Chat is down for maintenance");
  } finally {
    providers.stop();
  }
});

test("in remote mode, minProviders above 1 fails every sign-in", async () => {
  const providers = standInProviders();
  try {
    const remote = {
      ...server(),
      auth: {
        mode: "remote" as const,
        provider: "https://chat.example.com",
        clientId: "help-desk",
        providerConfig: { id: "chat", tokenEndpoint: "https://chat.example.com/oauth/token", userInfoEndpoint: "https://chat.example.com/userinfo" },
        local: { issuer: "https://desk.example.com" },
        federatedAuth: { stateValidation: "strict" as const, minProviders: 2 },
      },
    };
    const client = await Client.for(remote);
    const { location } = await client.openPicker(); // remote mode has no picker: straight to the provider
    const back = await client.returnFrom(location!);
    expect(back).toMatchObject({ status: 400, code: null });
    expect(back.page).toContain("Sign-in did not complete: a required provider was not linked.");
  } finally {
    providers.stop();
  }
});
```

```ts help-desk.app.ts hidden
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "find_customer", description: "Find the customer behind a ticket, in the CRM", inputSchema: { ticket: z.string() } })
export class FindCustomer extends ToolContext {
  async execute({ ticket }: { ticket: string }) {
    const token = await this.orchestration.tryGetToken("crm");
    if (!token) {
      this.fail(new PublicMcpError("The user hasn't linked the CRM. Ask them to sign in again and tick crm.", "LINK_CRM"));
    }
    const res = await this.fetch(`https://crm.example.com/api/customers?ticket=${ticket}`, { headers: { authorization: `Bearer ${token}` } });
    return await res.json();
  }
}

@Tool({ name: "whoami", description: "Say who the server thinks is calling, and which providers they linked", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    const { user, claims } = this.auth;
    return { sub: user.sub, email: user.email ?? null, name: user.name ?? null, linked: this.orchestration.getProviderIds(), federated: claims.federated ?? null };
  }
}

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

```ts providers.example.ts hidden
// Stands in for the chat and the CRM, which the Playground can't reach: it answers
// FrontMCP's requests to their token and userinfo endpoints, and the APIs the tools call.
export function standInProviders({ expiresIn = 3600, refreshToken = false, userInfo = true } = {}) {
  const realFetch = globalThis.fetch;
  const requests: { method: string; url: string; form?: Record<string, string>; token?: string }[] = [];
  let issued = 0;
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = new URL(input instanceof Request ? input.url : String(input));
    if (url.host !== "chat.example.com" && url.host !== "crm.example.com") return realFetch(input, init);
    const provider = url.host.split(".")[0];
    const method = init?.method ?? "GET";
    const isTokenEndpoint = method === "POST" && !url.pathname.startsWith("/api/");
    const form = isTokenEndpoint ? Object.fromEntries(new URLSearchParams(String(init?.body))) : undefined;
    const token = new Headers(init?.headers).get("authorization")?.replace(/^Bearer /, "");
    requests.push({ method, url: url.origin + url.pathname, ...(form ? { form } : {}), ...(token ? { token } : {}) });
    if (isTokenEndpoint) {
      issued += 1;
      return Response.json({ access_token: `${provider}-token-${issued}`, token_type: "Bearer", expires_in: expiresIn, ...(refreshToken ? { refresh_token: `${provider}-refresh-${issued}` } : {}) });
    }
    if (!token?.startsWith(`${provider}-token-`)) return new Response("Unauthorized", { status: 401 });
    if (url.pathname === "/userinfo" || url.pathname === "/me") {
      // userInfo: false answers like a plain OAuth API: an id, and no sub
      return Response.json(userInfo ? { sub: `${provider}|nour`, name: "Nour Haddad", email: `nour@${provider}.example.com` } : { id: 4242, login: "nour" });
    }
    if (url.pathname === "/api/customers") return Response.json({ customer: "Acme GmbH", plan: "enterprise" });
    if (url.pathname === "/api/messages") return Response.json({ ok: true }, { status: 201 });
    return new Response("Not found", { status: 404 });
  }) as typeof fetch;
  return { requests, stop: () => void (globalThis.fetch = realFetch) };
}
```

```ts sign-in.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://127.0.0.1:33418/callback";

/** An MCP client, and the user's browser with its cookies, signing in through the provider picker. */
export class Client {
  clientId = "";
  readonly verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // RFC 7636's example
  readonly cookies = new Map<string, string>();
  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new Client((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  async send(url: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(new URL(url, SERVER), { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const [name, value] = [pair.slice(0, pair.indexOf("=")), pair.slice(pair.indexOf("=") + 1)];
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** Registers, opens /oauth/authorize, and returns the page and the pending_auth_id its form carries. */
  async openPicker() {
    if (!this.clientId) {
      const registered = await this.send("/oauth/register", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: "none" }),
      });
      this.clientId = (await registered.json()).client_id;
    }
    const query = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: REDIRECT_URI, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", state: "client-state" });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, location: response.headers.get("location"), page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] ?? "" };
  }

  /** Submits the picker, as the browser does: the ticked providers and the email. */
  async choose(pendingAuthId: string, providers: string[], email = "nour@example.com") {
    const form = new URLSearchParams({ pending_auth_id: pendingAuthId, federated: "true", email });
    for (const id of providers) form.append("providers", id);
    return this.follow(await this.send("/oauth/callback", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form }));
  }

  /** The provider at `atProvider` sends the user back to FrontMCP's callback with these parameters. */
  async returnFrom(atProvider: string, params: Record<string, string> = { code: "code-from-the-provider" }) {
    const at = new URL(atProvider);
    const callback = new URL(at.searchParams.get("redirect_uri")!);
    callback.search = new URLSearchParams({ state: at.searchParams.get("state")!, ...params }).toString();
    return this.follow(await this.send(callback.href));
  }

  private async follow(response: Response) {
    const location = response.headers.get("location");
    const code = location?.startsWith(REDIRECT_URI) ? new URL(location).searchParams.get("code") : null;
    return { status: response.status, location, code, page: await response.text() };
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return response.json();
  }

  /** Every step: the picker, each provider in turn, and the code exchange. */
  async signIn(providers: string[], email = "nour@example.com") {
    let step = await this.choose((await this.openPicker()).pendingAuthId, providers, email);
    while (step.location && !step.code) step = await this.returnFrom(step.location);
    return this.token({ grant_type: "authorization_code", code: step.code!, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  async callTool(accessToken: string, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, authorization: `Bearer ${accessToken}` },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return (await response.json()).result;
  }
}
```

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](#linking-providers-at-sign-in).

### 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:

```ts server.ts active
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",
      },
    ],
  },
};
```

```ts identity.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { standInProviders } from "./providers.example";
import { Client } from "./sign-in";
import { config } from "./server";

test("a provider whose user API has no sub can't be linked", async () => {
  const providers = standInProviders({ userInfo: false }); // answers { id, login }, like GitHub's /user
  try {
    const client = await Client.for(config);
    const toChat = await client.choose((await client.openPicker()).pendingAuthId, ["chat"]);
    const back = await client.returnFrom(toChat.location!);
    expect(back).toMatchObject({ status: 400, code: null });
    expect(back.page).toContain("Could not determine your identity from the provider");
  } finally {
    providers.stop();
  }
});

test("with issuer, the provider's redirect must name it", async () => {
  const providers = standInProviders();
  try {
    const client = await Client.for(config);
    const toChat = await client.choose((await client.openPicker()).pendingAuthId, ["chat"]);
    const mixedUp = await client.returnFrom(toChat.location!, { code: "code-from-the-provider", iss: "https://login.example.net" });
    expect(mixedUp.status).toBe(400);
    expect(mixedUp.page).toContain("Authorization response came from an unexpected issuer.");
    expect(providers.requests).toEqual([]); // the code was never exchanged

    const named = await client.choose((await client.openPicker()).pendingAuthId, ["chat"]);
    expect((await client.returnFrom(named.location!, { code: "code-from-the-provider", iss: "https://chat.example.com" })).code).toEqual(expect.any(String));
  } finally {
    providers.stop();
  }
});

test("without issuer, an iss on the redirect is ignored", async () => {
  const providers = standInProviders();
  try {
    const { issuer, ...chat } = config.auth.providers[0];
    const client = await Client.for({ ...config, auth: { ...config.auth, providers: [chat] } });
    const toChat = await client.choose((await client.openPicker()).pendingAuthId, ["chat"]);
    expect((await client.returnFrom(toChat.location!, { code: "code-from-the-provider", iss: "https://login.example.net" })).code).toEqual(expect.any(String));
  } finally {
    providers.stop();
  }
});

test("a provider without its endpoints stops the server", async () => {
  const { tokenUrl, ...noTokenUrl } = config.auth.providers[0];
  await expect(FrontMcpInstance.createFetchHandler({ ...config, auth: { ...config.auth, providers: [noTokenUrl] } } as typeof config)).rejects.toThrow(
    "tokenEndpoint (or tokenUrl) is required",
  );
  const { authorizeUrl, ...noAuthorizeUrl } = config.auth.providers[0];
  await expect(FrontMcpInstance.createFetchHandler({ ...config, auth: { ...config.auth, providers: [noAuthorizeUrl] } } as typeof config)).rejects.toThrow(
    "authorizationEndpoint (or authorizeUrl) is required",
  );
});
```

```ts help-desk.app.ts hidden
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling, and which providers they linked", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    const { user, claims } = this.auth;
    return { sub: user.sub, email: user.email ?? null, name: user.name ?? null, linked: this.orchestration.getProviderIds(), federated: claims.federated ?? null };
  }
}

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

```ts providers.example.ts hidden
// Stands in for the chat and the CRM, which the Playground can't reach: it answers
// FrontMCP's requests to their token and userinfo endpoints, and the APIs the tools call.
export function standInProviders({ expiresIn = 3600, refreshToken = false, userInfo = true } = {}) {
  const realFetch = globalThis.fetch;
  const requests: { method: string; url: string; form?: Record<string, string>; token?: string }[] = [];
  let issued = 0;
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = new URL(input instanceof Request ? input.url : String(input));
    if (url.host !== "chat.example.com" && url.host !== "crm.example.com") return realFetch(input, init);
    const provider = url.host.split(".")[0];
    const method = init?.method ?? "GET";
    const isTokenEndpoint = method === "POST" && !url.pathname.startsWith("/api/");
    const form = isTokenEndpoint ? Object.fromEntries(new URLSearchParams(String(init?.body))) : undefined;
    const token = new Headers(init?.headers).get("authorization")?.replace(/^Bearer /, "");
    requests.push({ method, url: url.origin + url.pathname, ...(form ? { form } : {}), ...(token ? { token } : {}) });
    if (isTokenEndpoint) {
      issued += 1;
      return Response.json({ access_token: `${provider}-token-${issued}`, token_type: "Bearer", expires_in: expiresIn, ...(refreshToken ? { refresh_token: `${provider}-refresh-${issued}` } : {}) });
    }
    if (!token?.startsWith(`${provider}-token-`)) return new Response("Unauthorized", { status: 401 });
    if (url.pathname === "/userinfo" || url.pathname === "/me") {
      // userInfo: false answers like a plain OAuth API: an id, and no sub
      return Response.json(userInfo ? { sub: `${provider}|nour`, name: "Nour Haddad", email: `nour@${provider}.example.com` } : { id: 4242, login: "nour" });
    }
    if (url.pathname === "/api/customers") return Response.json({ customer: "Acme GmbH", plan: "enterprise" });
    if (url.pathname === "/api/messages") return Response.json({ ok: true }, { status: 201 });
    return new Response("Not found", { status: 404 });
  }) as typeof fetch;
  return { requests, stop: () => void (globalThis.fetch = realFetch) };
}
```

```ts sign-in.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://127.0.0.1:33418/callback";

/** An MCP client, and the user's browser with its cookies, signing in through the provider picker. */
export class Client {
  clientId = "";
  readonly verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // RFC 7636's example
  readonly cookies = new Map<string, string>();
  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new Client((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  async send(url: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(new URL(url, SERVER), { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const [name, value] = [pair.slice(0, pair.indexOf("=")), pair.slice(pair.indexOf("=") + 1)];
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** Registers, opens /oauth/authorize, and returns the page and the pending_auth_id its form carries. */
  async openPicker() {
    if (!this.clientId) {
      const registered = await this.send("/oauth/register", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: "none" }),
      });
      this.clientId = (await registered.json()).client_id;
    }
    const query = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: REDIRECT_URI, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", state: "client-state" });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, location: response.headers.get("location"), page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] ?? "" };
  }

  /** Submits the picker, as the browser does: the ticked providers and the email. */
  async choose(pendingAuthId: string, providers: string[], email = "nour@example.com") {
    const form = new URLSearchParams({ pending_auth_id: pendingAuthId, federated: "true", email });
    for (const id of providers) form.append("providers", id);
    return this.follow(await this.send("/oauth/callback", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form }));
  }

  /** The provider at `atProvider` sends the user back to FrontMCP's callback with these parameters. */
  async returnFrom(atProvider: string, params: Record<string, string> = { code: "code-from-the-provider" }) {
    const at = new URL(atProvider);
    const callback = new URL(at.searchParams.get("redirect_uri")!);
    callback.search = new URLSearchParams({ state: at.searchParams.get("state")!, ...params }).toString();
    return this.follow(await this.send(callback.href));
  }

  private async follow(response: Response) {
    const location = response.headers.get("location");
    const code = location?.startsWith(REDIRECT_URI) ? new URL(location).searchParams.get("code") : null;
    return { status: response.status, location, code, page: await response.text() };
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return response.json();
  }

  /** Every step: the picker, each provider in turn, and the code exchange. */
  async signIn(providers: string[], email = "nour@example.com") {
    let step = await this.choose((await this.openPicker()).pendingAuthId, providers, email);
    while (step.location && !step.code) step = await this.returnFrom(step.location);
    return this.token({ grant_type: "authorization_code", code: step.code!, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  async callTool(accessToken: string, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, authorization: `Bearer ${accessToken}` },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return (await response.json()).result;
  }
}
```

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`](https://frontmcp.dev/reference/auth/modes#auth-for-one-app) 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:

```ts server.ts active
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" } },
};
```

```ts tools.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { sub: this.auth.user.sub, federated: this.auth.claims.federated ?? null, linked: this.orchestration.getProviderIds() };
  }
}

@Tool({ name: "list_invoices", description: "List the customer's invoices", inputSchema: {} })
export class ListInvoices extends ToolContext {
  async execute() {
    return { invoices: ["INV-1"] };
  }
}
```

```ts apps.test.ts
import { test, expect } from "@frontmcp/testing";
import { Client, REDIRECT_URI } from "./sign-in";
import { config } from "./server";

test("the picker lists the server and the app", async () => {
  const { page } = await (await Client.for(config)).openPicker();
  expect(page).toMatch(/<input type="checkbox" name="providers" value="__parent__"\s+class="[^"]*" checked>/);
  expect(page).toContain('value="billing"');
  expect(page).toContain("Mode: transparent");
  expect(page).toContain("https://billing-sso.example.com");
});

test("ticking the app signs in to nothing", async () => {
  const requests: string[] = [];
  const realFetch = globalThis.fetch;
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
    requests.push(String(input instanceof Request ? input.url : input));
    return realFetch(input, init);
  }) as typeof fetch;
  try {
    const client = await Client.for(config);
    const step = await client.choose((await client.openPicker()).pendingAuthId, ["billing"]);
    expect(step.location).toMatch(/^http:\/\/127\.0\.0\.1:33418\/callback\?code=/); // straight back to the client
    expect(requests).toEqual([]); // billing-sso.example.com was never asked

    const { access_token } = await client.token({ grant_type: "authorization_code", code: step.code!, redirect_uri: REDIRECT_URI, client_id: client.clientId, code_verifier: client.verifier });
    expect((await client.callTool(access_token, "whoami")).structuredContent).toEqual({ sub: expect.any(String), federated: null, linked: [] });
    expect((await client.callTool(access_token, "list_invoices")).structuredContent).toEqual({ invoices: ["INV-1"] });
  } finally {
    globalThis.fetch = realFetch;
  }
});
```

```ts sign-in.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://127.0.0.1:33418/callback";

/** An MCP client, and the user's browser with its cookies, signing in through the provider picker. */
export class Client {
  clientId = "";
  readonly verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // RFC 7636's example
  readonly cookies = new Map<string, string>();
  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new Client((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  async send(url: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(new URL(url, SERVER), { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const [name, value] = [pair.slice(0, pair.indexOf("=")), pair.slice(pair.indexOf("=") + 1)];
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** Registers, opens /oauth/authorize, and returns the page and the pending_auth_id its form carries. */
  async openPicker() {
    if (!this.clientId) {
      const registered = await this.send("/oauth/register", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: "none" }),
      });
      this.clientId = (await registered.json()).client_id;
    }
    const query = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: REDIRECT_URI, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", state: "client-state" });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, location: response.headers.get("location"), page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] ?? "" };
  }

  /** Submits the picker, as the browser does: the ticked providers and the email. */
  async choose(pendingAuthId: string, providers: string[], email = "nour@example.com") {
    const form = new URLSearchParams({ pending_auth_id: pendingAuthId, federated: "true", email });
    for (const id of providers) form.append("providers", id);
    return this.follow(await this.send("/oauth/callback", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form }));
  }

  private async follow(response: Response) {
    const location = response.headers.get("location");
    const code = location?.startsWith(REDIRECT_URI) ? new URL(location).searchParams.get("code") : null;
    return { status: response.status, location, code, page: await response.text() };
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return response.json();
  }

  async callTool(accessToken: string, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, authorization: `Bearer ${accessToken}` },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return (await response.json()).result;
  }
}
```

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](https://frontmcp.dev/reference/auth/modes#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](https://frontmcp.dev/reference/auth/remote), 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](#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](https://frontmcp.dev/reference/auth/login-ui#authui-react-pages) 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](#where-the-users-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`](https://frontmcp.dev/reference/auth/local#options) 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`](https://frontmcp.dev/reference/server/config-files) to the same URL. [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote#this-sign-in-was-started-in-another-browser-or-at-another-address-start-it-again-from-the-app) 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](#linking-providers-at-sign-in). `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`](#apps-with-their-own-auth).
