# Testing authentication

> Test a FrontMCP server that needs a token: the auth fixture's token factory, clients that call as a user, MockOAuthServer as the identity provider of transparent and remote servers, MockCimdServer for clients that sign in with a metadata URL, and a full sign-in, with what each auth mode accepts.

Source: https://frontmcp.dev/reference/testing/auth

A server with `auth` refuses the `mcp` fixture's anonymous client, so its tests call as a user: make a token, and sign `mcp` in with it, or connect a client of your own. The `auth` fixture makes tokens that a `public`, `local` or `remote` server accepts, once the test gives the server a `JWT_SECRET`, which the fixture then signs with too. A `transparent` server only accepts tokens its identity provider signed, so its tests start a stand-in provider, `MockOAuthServer`, and sign tokens with its keys. To test the sign-in itself, a test walks it the way a browser does, with a client it registers, or a [CIMD](https://frontmcp.dev/reference/auth/cimd) client whose metadata document `MockCimdServer` serves. The Playground's test runner has no `auth` fixture: every example on this page was run with `frontmcp test` and Jest 30 on FrontMCP 1.9.1, and what the server does with each kind of token was checked again on 1.9.2.

```ts
test.use({ server: "./src/main.ts", env: { JWT_SECRET: "…" } });

test("name", async ({ server, auth }) => {
  const client = await server.createClient({ token: await auth.createToken({ sub, scopes?, email?, name?, claims?, expiresIn? }) });
});

new MockOAuthServer(new TestTokenFactory({ issuer, audience }), { port, autoApprove?, testUser?, clientId? });

new MockCimdServer({ port?, debug? }).registerClient({ name, redirectUris?, logoUri?, … }); // returns the client's client_id URL
```

---

## Reference

### What each auth mode accepts

| `auth.mode` | The `mcp` fixture | A token that works in tests |
| --- | --- | --- |
| `public` (the default) | Connects, as an anonymous caller with an `anon:` id | `auth.createToken()`, with `JWT_SECRET` in `test.use({ env })` |
| `static` | [Doesn't connect](https://frontmcp.dev/reference/testing/fixtures#mcp) | One of the server's `tokens` |
| `local`, `remote` | Doesn't connect | `auth.createToken()`, with `JWT_SECRET` in `test.use({ env })`, or the token from a [real sign-in](#signing-in-for-real) |
| `transparent` | Doesn't connect | A token from a `TestTokenFactory` whose keys a [`MockOAuthServer`](#mockoauthserver) serves as the `provider` |

Connect with the token through [`server.createClient({ token })`](https://frontmcp.dev/reference/testing/fixtures#servercreateclientoptions), or `server.createClientBuilder().withToken(token)`, or sign the `mcp` fixture in with `await mcp.authenticate(token)`. For what each mode does with a request, see [Auth modes](https://frontmcp.dev/reference/auth/modes).

### The `auth` fixture

#### `auth.createToken(options)`

Resolves to a signed JWT.

| Option | Type | Description |
| --- | --- | --- |
| `sub` | `string` | Required. The user's id: `this.auth.user.sub` in your tools. |
| `scopes` | `string[]` | The `scope` claim, space-separated. |
| `email`, `name` | `string` | The claims of the same name. |
| `claims` | `Record<string, unknown>` | More claims. They're added last, so they replace any claim, `sub`, `iss` and `aud` included: `claims: { aud: "https://other-desk.example.com" }` makes a token for another server. |
| `expiresIn` | `number` | How long the token lasts, in seconds. Default `3600`. `exp` is rounded up to a whole second, so the token lasts at least that long, and with `expiresIn: 1` it can't expire before its first use. (Before 1.8.7 `iat` was the current second rounded down and `exp` was `iat + expiresIn`, so a token with `expiresIn: 1` could expire before the first call.) |

How it signs depends on `test.use({ env })`:

| `env` | Signed with | `iss` and `aud` |
| --- | --- | --- |
| With `JWT_SECRET` | HS256 and that secret, as a `public`, `local` or `remote` server signs its own tokens | The server's MCP address: `baseUrl` and `entryPath`, like `http://localhost:51000`, or `http://localhost:51000/mcp` with `entryPath: "/mcp"` |
| Without | RS256, with a key made for the file | `https://test.frontmcp.local` and `frontmcp-test`. The server under test doesn't know this key, so it refuses these tokens. [`getJwks()`](#other-members) returns the key. |

#### Other members

| Member | Description |
| --- | --- |
| `createExpiredToken({ sub })` | A token issued two hours ago that expired an hour ago, with only `iss`, `sub`, `aud`, `iat` and `exp`. |
| `createInvalidToken({ sub })` | A token with the right header and claims, and a signature that isn't one. It isn't a promise. |
| `users` | Three users to pass to `createToken()`: see below. |
| `getIssuer()`, `getAudience()` | The `iss` and `aud` of its tokens. |
| `getJwks()` | Resolves to `{ keys: [publicKey] }`, the RS256 key its tokens are signed with. With `JWT_SECRET` it throws `getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)`. |

| User | `sub` | `scopes` | `email`, `name` |
| --- | --- | --- | --- |
| `auth.users.admin` | `admin-001` | `admin:*`, `read`, `write`, `delete` | `admin@test.local`, `Test Admin` |
| `auth.users.user` | `user-001` | `read`, `write` | `user@test.local`, `Test User` |
| `auth.users.readOnly` | `readonly-001` | `read` | `readonly@test.local`, `Read Only User` |

#### What happens to a token

- **Accepted:** tools see its claims. `this.auth.user` has `sub`, `email` and `name`, `this.auth.scopes` has its scopes, and `this.auth.claims` has every claim. (Under `frontmcp test`, which has a session, `this.auth.scopes` was `[]` before 1.8.7, and tools read `this.context.authInfo.scopes`.)
- **Refused:** `server.createClient()` rejects with `Failed to initialize MCP connection to http://localhost:51000/ after 1 attempt: HTTP 401: Unauthorized - {"error":"Unauthorized"}.` A `401` or `403` is tried once. The reason is only in the `401`'s `WWW-Authenticate` header: see [Seeing why a token is refused](#seeing-why-a-token-is-refused).
- **Expired during the test:** the client's next requests fail with `{ code: -32000, message: "HTTP 401: Unauthorized" }`, in `result.error`, and the `list()` methods throw `Failed to list tools: HTTP 401: Unauthorized`.

#### Caveats

- **`mcp.authenticate(token)` signs the `mcp` fixture in.** It opens a new session as the token's user, and the client keeps the token, so `mcp.reconnect()` uses it too. When the server refuses the token, it rejects with the error `createClient()` would give, and `mcp` keeps its old session and identity. On a server that needs a token, it's how a test uses `mcp` at all: `await mcp.authenticate(await auth.createToken({ sub: "agent-1" }))`.
- **`publicMode: true` only keeps `mcp` from asking for an anonymous token.** A client given a token, with `server.createClient({ token })` or `authenticate()`, sends it.
- **A server with `local.issuer`, or `FRONTMCP_PUBLIC_URL`, issues and expects another `iss`.** Pass it in `claims`: `auth.createToken({ sub, claims: { iss: "https://desk.example.com" } })`.
- **`test.use({ auth })` doesn't set the server's mode.** The server's mode is whatever its `@FrontMcp` says; `auth: { mode: "public" }` only keeps `mcp` anonymous, like `publicMode`.
- **A public server ignores a token that isn't a JWT**, and lets the caller in as anonymous. A JWT it can't verify gets `401`.
- **The `mcp` fixture tries to connect before every test**, and on a server that needs a token it prints its `could not connect anonymously` warning each time. The tests that use only `server` and `auth` are unaffected.

Before 1.8.7, `mcp.authenticate()` changed only the header, so the next request failed with `HTTP 404: Not Found` and `invalid session id`; and `publicMode: true` dropped the token of every client, so the server saw an anonymous caller. Both work as above now.

### `TestTokenFactory`

The factory behind `auth`, for tokens you sign yourself, like a transparent server's provider does.

```ts
import { TestTokenFactory } from "@frontmcp/testing";

const tokens = new TestTokenFactory({ issuer: "http://localhost:50900", audience: "https://desk.example.com" });
```

| Option | Default | Description |
| --- | --- | --- |
| `issuer` | `"https://test.frontmcp.local"` | The `iss` of its tokens. |
| `audience` | `"frontmcp-test"` | Their `aud`. |
| `hmacSecret` | | Sign with HS256 and this secret, instead of an RSA key the factory makes on first use. |

| Method | Returns |
| --- | --- |
| `createTestToken({ sub, iss?, aud?, scopes?, exp?, claims? })` | A token. `exp` is its lifetime in seconds, default `3600`, rounded up like `auth`'s `expiresIn`; `aud` can be a list. |
| `createAdminToken(sub?)`, `createUserToken(sub?, scopes?)` | The `admin-001` and `user-001` users, with a `role` claim of `admin` or `user`. |
| `createAnonymousToken(expiresIn?)` | `sub` `anon:<time>`, `scope` `anonymous`, `role` `anonymous`. `expiresIn` is its lifetime in seconds, default `3600`. |
| `createExpiredToken({ sub })`, `createTokenWithInvalidSignature({ sub })` | As `auth`'s `createExpiredToken()` and `createInvalidToken()`. |
| `getPublicJwks()`, `getIssuer()`, `getAudience()` | As `auth`'s `getJwks()`, `getIssuer()` and `getAudience()`. The key's `kid` is `test-key-<time>`. |

### `MockOAuthServer`

An OAuth 2.1 and OpenID Connect provider for tests, on a port of its own. It publishes a `TestTokenFactory`'s public key, so a server whose `provider` it is accepts that factory's tokens, and it runs the authorization-code flow with PKCE, so a `remote` server can sign users in through it.

```ts
import { MockOAuthServer, TestTokenFactory } from "@frontmcp/testing";

const idp = new MockOAuthServer(new TestTokenFactory({ issuer: "http://localhost:50900", audience: "help-desk-mcp" }), {
  port: 50900,
  autoApprove: true,
  testUser: { sub: "sso|nour", email: "nour@acme.dev", name: "Nour" },
  clientId: "help-desk-mcp",
});
```

| Option | Default | Description |
| --- | --- | --- |
| `port` | a random free port | Set one: the `TestTokenFactory` that signs the tokens needs the provider's URL as its `issuer` when you create it, and so does the server under test. |
| `issuer` | `http://localhost:<port>` | The `issuer` in its metadata, and the base of the endpoint URLs listed there. The tokens' `iss` is the factory's `issuer`, so give the factory the same URL. |
| `autoApprove` | `false` | `/oauth/authorize` redirects straight back with a code for `testUser`. Without it, it answers with a login page whose form asks for a `sub`, an email and a name, and has allow and deny buttons. |
| `testUser` | | `{ sub, email?, name?, picture?, claims? }`: who signs in with `autoApprove`, and who `/userinfo` describes. Required with `autoApprove`: without it, `/oauth/authorize` answers `500`. |
| `clientId` | | When set, other `client_id`s are redirected back with `error=unauthorized_client`. |
| `clientSecret` | | When set, `/oauth/token` requires it, in the form or with HTTP Basic auth, or answers `401` `invalid_client`. |
| `validRedirectUris` | any | The redirect URIs `/oauth/authorize` accepts, exactly or with `*`. Others get `400` `Invalid redirect_uri`. |
| `accessTokenTtlSeconds`, `refreshTokenTtlSeconds` | `3600`, 30 days | How long the access and ID tokens last, which is also the `expires_in` of token responses, and how long refresh tokens work. (Before 1.8.7 the tokens themselves always lasted an hour, whatever `accessTokenTtlSeconds` said.) |
| `debug` | `false` | Logs each request. |

| Member | Description |
| --- | --- |
| `start()` | Starts listening. Resolves to `{ baseUrl, port, issuer, jwksUrl }`. |
| `stop()` | Stops it, closing open connections. |
| `info` | What `start()` resolved to. Throws when it isn't running. |
| `setAutoApprove(enabled)`, `setTestUser(user)`, `addValidRedirectUri(uri)` | Change those options while it runs. |
| `clearStoredTokens()` | Forgets every code and refresh token it issued. |
| `getTokenFactory()` | The factory it was given. |

| Endpoint | What it does |
| --- | --- |
| `GET /.well-known/jwks.json` | The factory's public key. |
| `GET /.well-known/openid-configuration`, `GET /.well-known/oauth-authorization-server` | Metadata: the issuer, the endpoints, `jwks_uri`, and the grants it supports. |
| `GET /oauth/authorize` | With `autoApprove`, a `302` back to `redirect_uri` with `code` and `state`; otherwise the login page, which posts to `POST /oauth/authorize/submit`. |
| `POST /oauth/token` | Form-encoded. `authorization_code` (checks the client, the redirect URI and the PKCE verifier; a code works once), `refresh_token` (each refresh returns a new refresh token, and the old one stops working) and `anonymous`. It answers `access_token`, `token_type`, `expires_in`, `refresh_token`, `id_token` and `scope`. |
| `GET /userinfo` | `testUser`, for any bearer token. `401` without one. |

The access and ID tokens it issues carry the user's `sub`, `email`, `name` and `claims`, from the factory, with the factory's `iss` and `aud`. The access token also has a `scope` claim with the scopes the client asked for, and the token response's `scope` lists them; the ID token has no `scope`. (Before 1.8.7 neither token had one.)

`MockOAuthServer` and `TestTokenFactory` work anywhere: a plain Node script can import `@frontmcp/testing` and start a mock provider, and the code exchange needs no Jest flag. `test` and `expect` only work inside Jest, and throw `… can only be used inside a Jest test file` anywhere else. (Before 1.8.7, importing the package outside Jest threw `Do not import @jest/globals outside of the Jest test environment`, and the code exchange failed under Jest without `NODE_OPTIONS=--experimental-vm-modules`.)

### `MockCimdServer`

A stand-in for the web server of a [CIMD](https://frontmcp.dev/reference/auth/cimd) client, on a port of its own. It serves the metadata document of each client a test registers, at `http://localhost:<port>/clients/<name>/metadata.json`, and that URL is the client's `client_id`. FrontMCP fetches documents only from `https` URLs on public addresses, so the server under test accepts these only with [`cimd.security.allowInsecureForTesting`](https://frontmcp.dev/reference/auth/cimd#options) on: see [Testing a CIMD client](#testing-a-cimd-client).

```ts
import { MockCimdServer } from "@frontmcp/testing";

const clients = new MockCimdServer();
await clients.start();
const clientId = clients.registerClient({ name: "Notes Desktop" });
// http://localhost:<port>/clients/notes-desktop/metadata.json
```

| Option | Default | Description |
| --- | --- | --- |
| `port` | a random free port | The port it listens on. The server under test doesn't need to know it: the `client_id` carries it. |
| `debug` | `false` | Logs each request, and each client registered or removed. |

| Member | Description |
| --- | --- |
| `start()` | Starts listening. Resolves to `{ baseUrl, port }`, where `baseUrl` is `http://localhost:<port>`. Throws `Mock CIMD server is already running` when it is. |
| `stop()` | Stops it. |
| `info` | What `start()` resolved to. Throws `Mock CIMD server is not running` when it isn't. |
| `registerClient(options)` | Serves a client's document, and returns its URL: the `client_id`. Before `start()`, throws `Mock CIMD server is not running. Call start() first.` |
| `getClientId(name)` | The URL of the client registered with that `name`. Throws `Client "<name>" not found. Register it first.` for a name it doesn't serve, and for a client registered with a `path` of its own. |
| `registerInvalidDocument(path, document)` | Serves `document`, any JSON, at `path`, without checking it, for a test of a document FrontMCP should refuse. |
| `registerFetchError(path, status, body?)` | Answers requests for `path` with that status, and `body` as JSON. It wins over a document at the same path, and works before `start()`. |
| `removeClient(name)` | Stops serving the client registered with that `name`. A client registered with a `path` of its own stays. |
| `clear()` | Stops serving every document and error. |

`registerClient()` builds the document from its options:

| Option | Default | Becomes |
| --- | --- | --- |
| `name` | Required | `client_name`, and the path: lower case, with each run of other characters turned into `-`, so `Notes Desktop` is served at `/clients/notes-desktop/metadata.json`. |
| `path` | From `name` | The part of the path between `/clients/` and `/metadata.json`. |
| `redirectUris` | `["http://localhost:3000/callback"]` | `redirect_uris`. The default is the one the [`signIn()` helper](#signing-in-for-real) uses. |
| `tokenEndpointAuthMethod` | `"none"` | `token_endpoint_auth_method`. |
| `grantTypes`, `responseTypes` | `["authorization_code"]`, `["code"]` | `grant_types`, `response_types`. |
| `clientUri`, `logoUri`, `scope`, `contacts` | | `client_uri`, `logo_uri`, `scope`, `contacts`, when set. |

The document's `client_id` is its own URL, as CIMD requires. It's served with `Cache-Control: max-age=3600`, and FrontMCP keeps a document [as long as that says](https://frontmcp.dev/reference/auth/cimd#caching), so a server under test that fetched one keeps using it for an hour: it still signed a client in after `removeClient()`. Other paths answer `404` `{"error":"not_found","message":"No client at <path>"}`, and requests other than `GET` and `OPTIONS` answer `405`.

### Signing in for real

`auth.createToken()` skips the sign-in: your [`authenticate`](https://frontmcp.dev/reference/auth/local#checking-users-yourself) doesn't run, and neither does a `remote` server's trip to the provider. To test those, a test signs in as a browser does. `@frontmcp/testing` has no helper for it, and a browser's cookies matter: `/oauth/authorize` sets a [sign-in cookie](https://frontmcp.dev/reference/auth/local#the-sign-in-cookie) that the callback refuses to go on without. This helper keeps cookies, follows redirects, fills in local mode's sign-in form, and trades the code for tokens:

```ts e2e/sign-in.ts
import { createHash, randomBytes } from "node:crypto";

const REDIRECT_URI = "http://localhost:3000/callback";

/**
 * Signs in to a local or remote server as a user's browser does, following
 * every redirect and keeping cookies, and returns the token response.
 * `fields` fill in local mode's sign-in form. Without `clientId`, it registers a client;
 * a CIMD client passes its metadata URL instead.
 */
export async function signIn(baseUrl: string, fields: Record<string, string> = {}, scope?: string, clientId?: string) {
  const cookies = new Map<string, string>();
  const send = async (url: string, init: RequestInit = {}) => {
    const headers = new Headers(init.headers);
    if (cookies.size) headers.set("cookie", [...cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await fetch(url, { ...init, headers, redirect: "manual" });
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const name = pair.slice(0, pair.indexOf("="));
      const value = pair.slice(pair.indexOf("=") + 1);
      if (value) cookies.set(name, value);
      else cookies.delete(name);
    }
    return response;
  };

  // Register a client, unless a CIMD client's metadata URL was given, and make a PKCE verifier and its challenge.
  let client_id = clientId;
  if (!client_id) {
    const registration = await send(`${baseUrl}/oauth/register`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "tests", redirect_uris: [REDIRECT_URI] }),
    });
    client_id = (await registration.json()).client_id as string;
  }
  const verifier = randomBytes(32).toString("base64url");
  const challenge = createHash("sha256").update(verifier).digest("base64url");

  const query = new URLSearchParams({
    response_type: "code",
    client_id,
    redirect_uri: REDIRECT_URI,
    code_challenge: challenge,
    code_challenge_method: "S256",
    state: "test",
    ...(scope ? { scope } : {}),
  });
  let response = await send(`${baseUrl}/oauth/authorize?${query}`);

  for (let step = 0; step < 10; step++) {
    const location = response.headers.get("location");
    if (location?.startsWith(REDIRECT_URI)) {
      // Back at the client, with a code: trade it for tokens.
      const code = new URL(location).searchParams.get("code");
      if (!code) throw new Error(`Sign-in failed: ${location}`);
      const tokens = await send(`${baseUrl}/oauth/token`, {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id, code_verifier: verifier }),
      });
      return tokens.json();
    }
    if (location) {
      response = await send(new URL(location, response.url).toString());
      continue;
    }
    // Local mode's sign-in page: submit its form.
    const page = await response.text();
    const pendingAuthId = page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1];
    if (response.status !== 200 || !pendingAuthId) {
      const text = page.replace(/<style[^]*?<\/style>/, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
      throw new Error(`Sign-in stopped with ${response.status}: ${text.slice(0, 300)}`);
    }
    response = await send(`${baseUrl}/oauth/callback`, {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ pending_auth_id: pendingAuthId, ...fields }),
    });
  }
  throw new Error("Sign-in didn't finish in 10 steps");
}
```

It keeps cookies by name only, which is enough for one server and its provider on `localhost`. A [CIMD](#testing-a-cimd-client) client passes its metadata URL as `clientId`, and isn't registered. [Local auth](https://frontmcp.dev/reference/auth/local#the-flow-a-client-follows) describes each step.

---

## Usage

The examples test this app:

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

@Tool({ name: "whoami", description: "Who is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    const { user, isAnonymous } = this.auth;
    return { sub: user.sub, email: user.email ?? null, anonymous: isAnonymous };
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket. Needs tickets:write.", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (!this.auth.hasScope("tickets:write")) this.fail(new PublicMcpError("Closing tickets needs tickets:write."));
    return { closed: id, by: this.auth.user.sub };
  }
}

@App({ name: "help-desk", tools: [WhoAmI, CloseTicket] })
export class HelpDesk {}
```

### Calling as a signed-in user

Give the server a `JWT_SECRET` in `test.use()`, and `auth.createToken()` makes tokens it accepts. This server is in `local` mode; `public` and `remote` servers work the same way:

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: { mode: "local", allowedScopes: ["tickets:read", "tickets:write"] },
})
export default class Server {}
```

```ts e2e/signed-in.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./src/main.ts", env: { JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });

test("tools see the user the token names", async ({ server, auth }) => {
  const token = await auth.createToken({ sub: "agent-1", email: "nour@acme.dev", scopes: ["tickets:write"] });
  const nour = await server.createClient({ token });
  try {
    expect((await nour.tools.call("whoami")).json()).toEqual({ sub: "agent-1", email: "nour@acme.dev", anonymous: false });
    expect((await nour.tools.call("close_ticket", { id: "T-1" })).json()).toEqual({ closed: "T-1", by: "agent-1" });
  } finally {
    await nour.disconnect();
  }
});

test("a token without tickets:write can't close tickets", async ({ server, auth }) => {
  const lin = await server.createClient({ token: await auth.createToken({ sub: "agent-2", scopes: ["tickets:read"] }) });
  try {
    const result = await lin.tools.call("close_ticket", { id: "T-1" });
    expect(result).toBeError();
    expect(result.text()).toBe("Closing tickets needs tickets:write.");
  } finally {
    await lin.disconnect();
  }
});

test("the built-in users", async ({ server, auth }) => {
  const readOnly = await server.createClient({ token: await auth.createToken(auth.users.readOnly) });
  try {
    expect((await readOnly.tools.call("whoami")).json()).toMatchObject({ sub: "readonly-001", email: "readonly@test.local" });
  } finally {
    await readOnly.disconnect();
  }
});
```

A token's scopes don't have to be in the server's `allowedScopes`: that option limits what a sign-in grants, and FrontMCP doesn't check it against a token it's given.

### Checking that tokens are refused

A refused token makes `server.createClient()` reject, so `rejects.toThrow()` checks it. `claims` makes a token that's wrong in one way:

```ts e2e/refused.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

test.use({ server: "./src/main.ts", env: { JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });

const refused = "HTTP 401: Unauthorized";

test("no token", async ({ server }) => {
  await expect(server.createClient()).rejects.toThrow(refused);
});

test("an expired token", async ({ server, auth }) => {
  await expect(server.createClient({ token: await auth.createExpiredToken({ sub: "agent-1" }) })).rejects.toThrow(refused);
});

test("a token with a bad signature", async ({ server, auth }) => {
  await expect(server.createClient({ token: auth.createInvalidToken({ sub: "agent-1" }) })).rejects.toThrow(refused);
});

test("a token for another server", async ({ server, auth }) => {
  const token = await auth.createToken({ sub: "agent-1", claims: { aud: "https://other-desk.example.com" } });
  await expect(server.createClient({ token })).rejects.toThrow(refused);
});

test("a token that expires during the test", async ({ server, auth }) => {
  const client = await server.createClient({ token: await auth.createToken({ sub: "agent-1", expiresIn: 2 }) });
  try {
    expect(await client.tools.call("whoami")).toBeSuccessful();
    await new Promise((resolve) => setTimeout(resolve, 3000));
    const result = await client.tools.call("whoami");
    expect(result.error).toMatchObject({ code: -32000, message: refused });
  } finally {
    await client.disconnect();
  }
});
```

### Seeing why a token is refused

The test client only reports `HTTP 401: Unauthorized`. The server says why in the `WWW-Authenticate` header, which a request of your own can read:

```ts
test("why a token is refused", async ({ server, auth }) => {
  const token = await auth.createExpiredToken({ sub: "agent-1" });
  const response = await fetch(`${server.info.baseUrl}/`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "initialize",
      params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0.0" } },
    }),
  });
  expect(response.status).toBe(401);
  expect(response.headers.get("www-authenticate")).toContain('error_description="\\"exp\\" claim timestamp check failed"');
});
```

The header here is `Bearer resource_metadata="http://localhost:51000/.well-known/oauth-protected-resource", error="invalid_token", error_description="\"exp\" claim timestamp check failed"`. [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens#errors-a-client-sees) lists the descriptions.

### Testing a static-key server

A `static` server accepts the keys in its `tokens`, as bearer tokens:

```ts e2e/static.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";

// src/static.ts has auth: { mode: "static", tokens: [process.env.DESK_KEY!] }
test.use({ server: "./src/static.ts", env: { DESK_KEY: "desk-key-1" } });

test("the key is accepted", async ({ server }) => {
  const client = await server.createClient({ token: "desk-key-1" });
  try {
    expect((await client.tools.call("whoami")).json()).toMatchObject({ anonymous: false });
  } finally {
    await client.disconnect();
  }
});

test("another key isn't", async ({ server }) => {
  await expect(server.createClient({ token: "desk-key-2" })).rejects.toThrow("HTTP 401: Unauthorized");
});
```

### Testing a transparent server

A `transparent` server checks tokens against its provider's keys, and refuses HS256, so `auth.createToken()` can't make one it accepts. Start a `MockOAuthServer` as the provider, on a fixed port, and sign tokens with the factory it serves the keys of. The factory's `issuer` is the provider's URL, and its `audience` the server's `expectedAudience`:

```ts src/transparent.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: {
    mode: "transparent",
    provider: process.env.IDP_URL!,
    expectedAudience: "https://desk.example.com",
  },
})
export default class Server {}
```

```ts e2e/transparent.e2e.spec.ts
import { test, expect, MockOAuthServer, TestTokenFactory } from "@frontmcp/testing";

const IDP_URL = "http://localhost:50900";
const tokens = new TestTokenFactory({ issuer: IDP_URL, audience: "https://desk.example.com" });
const idp = new MockOAuthServer(tokens, { port: 50900 });

test.use({ server: "./src/transparent.ts", env: { IDP_URL } });
test.beforeAll(() => idp.start());
test.afterAll(() => idp.stop());

test("a token from the provider is accepted", async ({ server }) => {
  const token = await tokens.createTestToken({ sub: "sso|nour", scopes: ["tickets:write"], claims: { email: "nour@acme.dev" } });
  const nour = await server.createClient({ token });
  try {
    expect((await nour.tools.call("whoami")).json()).toEqual({ sub: "sso|nour", email: "nour@acme.dev", anonymous: false });
    expect(await nour.tools.call("close_ticket", { id: "T-1" })).toBeSuccessful();
  } finally {
    await nour.disconnect();
  }
});

test("tokens the provider didn't sign, or not for this server, are refused", async ({ server, auth }) => {
  const refused = "HTTP 401: Unauthorized";
  // The auth fixture signs with a key of its own, which the provider doesn't publish
  await expect(server.createClient({ token: await auth.createToken({ sub: "sso|nour" }) })).rejects.toThrow(refused);
  await expect(server.createClient({ token: await tokens.createExpiredToken({ sub: "sso|nour" }) })).rejects.toThrow(refused);
  await expect(
    server.createClient({ token: await tokens.createTestToken({ sub: "sso|nour", aud: "https://other-desk.example.com" }) }),
  ).rejects.toThrow(refused);
});
```

`beforeAll` runs before the server starts, so the provider is up when the server first asks it for keys. The server finds them at the mock's `/.well-known/jwks.json`. (Before 1.9.2 it found them through `/.well-known/oauth-authorization-server` instead.)

### Testing the sign-in

With the [`signIn()` helper](#signing-in-for-real), a test signs in through the server's own page, and gets a token as a client would:

```ts e2e/local-sign-in.e2e.spec.ts
import { test, expect } from "@frontmcp/testing";
import { signIn } from "./sign-in";

test.use({ server: "./src/main.ts", env: { JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });

test("signing in with the built-in page", async ({ server }) => {
  const tokens = await signIn(server.info.baseUrl, { email: "nour@acme.dev", name: "Nour" }, "tickets:write");
  expect(tokens).toMatchObject({ token_type: "Bearer", scope: "tickets:write" });

  const nour = await server.createClient({ token: tokens.access_token });
  try {
    expect((await nour.tools.call("whoami")).json()).toMatchObject({ email: "nour@acme.dev", anonymous: false });
    expect(await nour.tools.call("close_ticket", { id: "T-1" })).toBeSuccessful();
  } finally {
    await nour.disconnect();
  }
});
```

For a server with your own `authenticate` and `login.fields`, pass your fields, like `{ deskKey: "…" }`: `signIn()` submits them as the form does.

### Testing a remote server's sign-in

A `remote` server sends the user to its provider. With `MockOAuthServer` as the provider and `autoApprove`, the provider signs `testUser` in at once, and the helper follows it back. The factory's `audience` is the server's `clientId`, which FrontMCP checks the provider's ID token against:

```ts src/remote.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

const idp = process.env.IDP_URL!;

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: {
    mode: "remote",
    provider: idp,
    clientId: "help-desk-mcp",
    scopes: ["openid", "profile", "email"],
    providerConfig: { id: "idp", authEndpoint: `${idp}/oauth/authorize`, tokenEndpoint: `${idp}/oauth/token` },
  },
})
export default class Server {}
```

```ts e2e/remote-sign-in.e2e.spec.ts
import { test, expect, MockOAuthServer, TestTokenFactory } from "@frontmcp/testing";
import { signIn } from "./sign-in";

const IDP_URL = "http://localhost:50901";
const idp = new MockOAuthServer(new TestTokenFactory({ issuer: IDP_URL, audience: "help-desk-mcp" }), {
  port: 50901,
  autoApprove: true,
  testUser: { sub: "sso|nour", email: "nour@acme.dev", name: "Nour" },
  clientId: "help-desk-mcp",
});

test.use({ server: "./src/remote.ts", env: { IDP_URL, JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });
test.beforeAll(() => idp.start());
test.afterAll(() => idp.stop());

test("signing in through the provider", async ({ server }) => {
  const tokens = await signIn(server.info.baseUrl);
  const nour = await server.createClient({ token: tokens.access_token });
  try {
    expect((await nour.tools.call("whoami")).json()).toEqual({ sub: "sso|nour", email: "nour@acme.dev", anonymous: false });
  } finally {
    await nour.disconnect();
  }
});
```

Run it like any other file:

```bash
npx frontmcp test --runInBand e2e/remote-sign-in.e2e.spec.ts
```

The provider's `redirect_uri` is built from `http.port`, which defaults to `PORT`, the port the test server is started on, so the `http.port` line can go. Before 1.9.2, a server without it built the `redirect_uri` with port `3001`, and `signIn()` failed with `fetch failed`. See [Registering the callback URL](https://frontmcp.dev/reference/auth/remote#registering-the-callback-url).

### Testing a CIMD client

A [CIMD](https://frontmcp.dev/reference/auth/cimd) client doesn't register: its `client_id` is the URL of its metadata document. A test starts a [`MockCimdServer`](#mockcimdserver) to serve that document, registers the client, and signs in with the URL, which the [`signIn()` helper](#signing-in-for-real) takes as its fourth argument. FrontMCP fetches a document only from an `https` URL on a public address, so the server turns on `allowInsecureForTesting` when the test's environment asks for it:

```ts src/cimd.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { port: Number(process.env.PORT ?? 3000) },
  auth: {
    mode: "local",
    allowedScopes: ["tickets:read", "tickets:write"],
    // Lets a client publish its document at http://localhost, as MockCimdServer does. Tests only.
    cimd: { security: { allowInsecureForTesting: process.env.CIMD_ON_LOCALHOST === "1" } },
  },
})
export default class Server {}
```

```ts e2e/cimd.e2e.spec.ts
import { test, expect, MockCimdServer } from "@frontmcp/testing";
import { signIn } from "./sign-in";

const clients = new MockCimdServer();

test.use({ server: "./src/cimd.ts", env: { CIMD_ON_LOCALHOST: "1", JWT_SECRET: "a-test-secret-of-at-least-32-bytes!!" } });
test.beforeAll(() => clients.start());
test.afterAll(() => clients.stop());

test("a client signs in with its metadata URL as client_id", async ({ server }) => {
  const clientId = clients.registerClient({ name: "Notes Desktop" });
  expect(clientId).toBe(`${clients.info.baseUrl}/clients/notes-desktop/metadata.json`);

  const tokens = await signIn(server.info.baseUrl, { email: "nour@acme.dev" }, "tickets:write", clientId);
  const nour = await server.createClient({ token: tokens.access_token });
  try {
    expect((await nour.tools.call("whoami")).json()).toMatchObject({ email: "nour@acme.dev", anonymous: false });
  } finally {
    await nour.disconnect();
  }
});

test("a redirect URI the document doesn't list is refused", async ({ server }) => {
  const clientId = clients.registerClient({ name: "Notes Web", redirectUris: ["https://notes.example.com/callback"] });
  await expect(signIn(server.info.baseUrl, { email: "nour@acme.dev" }, undefined, clientId)).rejects.toThrow("is not registered for client");
});

test("a document that can't be fetched, or isn't valid, is refused", async ({ server }) => {
  const { baseUrl } = clients.info;
  clients.registerFetchError("/clients/gone/metadata.json", 500);
  await expect(signIn(server.info.baseUrl, {}, undefined, `${baseUrl}/clients/gone/metadata.json`)).rejects.toThrow(
    "Failed to fetch CIMD document",
  );

  // No client_name
  clients.registerInvalidDocument("/clients/nameless/metadata.json", {
    client_id: `${baseUrl}/clients/nameless/metadata.json`,
    redirect_uris: ["http://localhost:3000/callback"],
  });
  await expect(signIn(server.info.baseUrl, {}, undefined, `${baseUrl}/clients/nameless/metadata.json`)).rejects.toThrow(
    "CIMD document validation failed: client_name",
  );
});
```

`MockCimdServer` runs in the test's process, and the server under test, in its own, fetches the documents from it over HTTP. A refused document stops the sign-in at `/oauth/authorize`, with the `400` page that [CIMD's troubleshooting](https://frontmcp.dev/reference/auth/cimd#troubleshooting) lists, and `signIn()` throws `Sign-in stopped with 400:` and the page's text. Each test registers a client of its own, because the server keeps a document it fetched for an hour. This was run with `frontmcp test` and Jest 30 on FrontMCP 1.9.4.

> **Pitfall: Only a test sets the variable**
`allowInsecureForTesting` lets in `http://localhost` documents, and also turns off every address check, for every client: with it on, a sign-in can make the server fetch from a private address. Set the variable only in `test.use({ env })`, never in a server's real environment. See [Keeping the fetch off your network](https://frontmcp.dev/reference/auth/cimd#keeping-the-fetch-off-your-network).

---

## Troubleshooting

### `Failed to initialize MCP connection to http://localhost:51000/ after 1 attempt: HTTP 401: Unauthorized - …`

The server refused the client's token, or it had none. Check that `test.use({ env })` has a `JWT_SECRET` (without it, `auth` tokens are RS256 and no server accepts them), that `publicMode` is off, and that the token's `iss` and `aud` are the server's: a server with `local.issuer` needs `claims: { iss }`. For a `transparent` server, sign with the factory its `MockOAuthServer` serves. [Seeing why a token is refused](#seeing-why-a-token-is-refused) reads the server's reason.

### `getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)`

`auth.getJwks()` was called with a `JWT_SECRET` in `test.use({ env })`. Those tokens are checked with the secret, and have no public key.

### `error_description="no_provider_verified (kid=test-key-…)"`

A `transparent` server couldn't verify the token with its provider's keys: it was signed by another factory (like the `auth` fixture's), its `iss` isn't the `provider`, or it expired. Give the factory the provider's URL as `issuer`, and pass that factory to the `MockOAuthServer`.

### `expect() from @frontmcp/testing can only be used inside a Jest test file`

`expect()` or `test()` from `@frontmcp/testing` ran outside Jest, in a script or another test runner. The package imports anywhere, and `TestTokenFactory`, `MockOAuthServer` and `httpMock` work there, but `test` and `expect` need Jest: run the file with `frontmcp test`.

### `Sign-in stopped with 400: … Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL` for a `MockCimdServer` client

The server under test doesn't have `cimd.security.allowInsecureForTesting` on, so it doesn't count the client's `http://localhost` URL as a metadata URL at all, and refuses it as a client it doesn't know. Turn the option on through the test's environment, as in [Testing a CIMD client](#testing-a-cimd-client).

### `Sign-in stopped with 200` from the `signIn()` helper

The page it reached isn't local mode's sign-in form: for example, `MockOAuthServer`'s own login page, shown when `autoApprove` is off. Turn `autoApprove` on, with a `testUser`.
