Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ MCP_AUTH_TOKEN=
# audience is still verified, just against a value your IdP actually issues.
# Matched exactly — these are opaque identifiers, so no normalisation is applied.
# OAUTH_AUDIENCE=00000000-0000-0000-0000-000000000000
# Scopes advertised to clients, so a client knows what to put in the authorization request.
# Separate with commas or spaces — a scope value can never contain a space (RFC 6749 3.3).
# Applies to BOTH well-known documents (protected-resource and authorization-server) so the
# two cannot disagree. Unset advertises nothing in the protected-resource doc and leaves the
# authorization-server doc on its `openid email profile` default, which is what this server
# did before the option existed. Set it for IdPs that reject a request without a scope
# parameter (Microsoft Entra fails with AADSTS900144).
# OAUTH_SCOPES_SUPPORTED=openid,email,profile
#
# Overrides (defaults derived from OAUTH_ISSUER):
# OAUTH_JWKS_URL=https://your-tenant.authkit.app/oauth2/jwks
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`OAUTH_VERIFY_AUDIENCE=false`, which accepts *any* token from the issuer (confused-deputy risk).
`OAUTH_VERIFY_AUDIENCE` stays `true` with this option — the check is still enforced, just against a
value the IdP actually issues. Unset by default; no change for existing deployments.
- **`OAUTH_SCOPES_SUPPORTED`: advertise scopes in the protected-resource metadata.** Comma-separated;
passed through to `mcpAuthMetadataRouter` as `scopesSupported`, which publishes it as
`scopes_supported` (RFC 9728). The SDK has always supported the option, but the server never passed
it and there was no way to configure it, so the protected-resource document named no scopes at all.
A client that discovers the server through that document therefore has nothing to put in the
authorization request's `scope` parameter and may omit it — which some IdPs reject outright
(Microsoft Entra: `AADSTS900144: The request body must contain the following parameter: 'scope'`),
breaking sign-in before it starts. Unset by default: no scopes are advertised and the document is
unchanged, so existing deployments are unaffected.

The value drives **both** well-known documents. `buildOAuthMetadata` previously hardcoded
`scopes_supported: ["openid","email","profile"]` on the authorization-server document, so
configuring scopes for a non-WorkOS IdP would have left the two documents contradicting each other
— the protected-resource doc naming (say) `api://<id>/mcp.access` while the authorization-server doc
still claimed `openid email profile`. When `OAUTH_SCOPES_SUPPORTED` is unset the authorization-server
document keeps that historic default, so existing deployments see no change.

Scopes may be separated by commas **or** whitespace. A scope value can never contain a space
(RFC 6749 §3.3), so `OAUTH_SCOPES_SUPPORTED="openid email profile"` — the form scopes take
everywhere else in OAuth — is unambiguous, and previously became a single invalid scope.

## [0.1.7]

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ LEXWARE_API_KEY=... MCP_AUTH_TOKEN=... npm start
| `OAUTH_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allow-list of email domains (e.g. `example.com`) |
| `OAUTH_VERIFY_AUDIENCE` | `true` | Verify the token `aud` matches `OAUTH_RESOURCE`. **Keep `true`.** Setting `false` accepts *any* valid token from the issuer — including one minted for a different app on the same issuer (a confused-deputy risk). Only disable for a dedicated, single-audience issuer that has no Resource Indicator |
| `OAUTH_AUDIENCE` | — | Comma-separated **additional** accepted `aud` values, on top of `OAUTH_RESOURCE`. For IdPs that ignore the Resource Indicator: Microsoft Entra always puts the API's client ID (a GUID) in `aud`, never the Application ID URI, so without this every token is rejected. Prefer this over `OAUTH_VERIFY_AUDIENCE=false` — the check stays on, just against a value your IdP actually issues. Values are matched **exactly**: they are opaque identifiers, so no normalisation is applied (unlike `OAUTH_RESOURCE`, which also accepts its trailing-slash form) |
| `OAUTH_SCOPES_SUPPORTED` | — | Scopes advertised as `scopes_supported`, telling clients what to request. Separate with commas **or** spaces (a scope value can never contain a space). Applies to **both** well-known documents, so they can't contradict each other. Unset: the protected-resource doc advertises nothing and the authorization-server doc keeps its `openid email profile` default — i.e. unchanged behaviour. Set it for IdPs that reject an authorization request with no `scope` parameter (Microsoft Entra: `AADSTS900144`) |
| `OAUTH_JWKS_URL` / `OAUTH_USERINFO_URL` | derived from issuer | Override the JWKS / OIDC userinfo endpoints (defaults use the WorkOS-AuthKit layout) |
| `OAUTH_AUTHORIZATION_ENDPOINT` / `OAUTH_TOKEN_ENDPOINT` / `OAUTH_REGISTRATION_ENDPOINT` | derived from issuer | Override the endpoints advertised in the authorization-server metadata. Defaults use the WorkOS layout (`{issuer}/oauth2/*`); set these for other IdPs (e.g. Auth0: `/authorize`, `/oauth/token`) |
| `MCP_AUTH_TOKEN` | — (**required**¹) | Static bearer token clients send to reach `/mcp` (used when OAuth is off) |
Expand Down
1 change: 1 addition & 0 deletions node_modules
18 changes: 18 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export type AuthConfig =
* rejected. Comma-separated via OAUTH_AUDIENCE.
*/
extraAudiences: string[];
/**
* Scopes advertised in the protected-resource metadata (RFC 9728
* `scopes_supported`), so a client knows what to request. Empty means "advertise
* nothing", which leaves the document exactly as it was before this option existed.
* Needed for IdPs that reject an authorization request without a `scope` parameter
* (Microsoft Entra: AADSTS900144). Comma-separated via OAUTH_SCOPES_SUPPORTED.
*/
scopesSupported: string[];
/** If non-empty, the user's email domain must be one of these (hard backstop). */
allowedEmailDomains: string[];
/** OIDC userinfo endpoint, used to fetch email when it isn't a token claim. */
Expand Down Expand Up @@ -186,6 +194,15 @@ function resolveAuth(env: NodeJS.ProcessEnv): AuthConfig {
.split(",")
.map((a) => a.trim())
.filter(Boolean);
// Not run through normalizeUrl: scopes are opaque strings, and IdPs use both bare
// names ("openid") and URI-shaped ones ("api://<client-id>/mcp.access").
// Split on commas AND whitespace: a scope value can never contain a space
// (RFC 6749 §3.3), so `openid email` — the form scopes appear in everywhere else
// in OAuth — is unambiguous and must not become one bogus scope named "openid email".
const scopesSupported = (env.OAUTH_SCOPES_SUPPORTED ?? "")
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
// Endpoints default to the WorkOS-AuthKit layout but are overridable so other
// IdPs (Auth0: /authorize + /oauth/token; Keycloak; Clerk) advertise correctly
// in the /.well-known/oauth-authorization-server metadata.
Expand All @@ -211,6 +228,7 @@ function resolveAuth(env: NodeJS.ProcessEnv): AuthConfig {
resource,
verifyAudience,
extraAudiences,
scopesSupported,
allowedEmailDomains,
userinfoUrl,
authorizationEndpoint,
Expand Down
33 changes: 32 additions & 1 deletion src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface OAuthSettings {
verifyAudience: boolean;
/** Extra accepted `aud` values (see AuthConfig.extraAudiences). */
extraAudiences?: string[];
/** Scopes to advertise in the protected-resource metadata (see AuthConfig.scopesSupported). */
scopesSupported?: string[];
allowedEmailDomains: string[];
userinfoUrl: string;
/** Authorization endpoint advertised in AS metadata. Defaults to `${issuer}/oauth2/authorize`. */
Expand All @@ -37,6 +39,12 @@ export function isEmailDomainAllowed(email: string | undefined, allowed: string[
return allowed.map((d) => d.toLowerCase()).includes(domain);
}

/**
* Scopes advertised when `OAUTH_SCOPES_SUPPORTED` is unset. Historic default, kept so
* an existing deployment's authorization-server metadata is unchanged.
*/
const DEFAULT_ADVERTISED_SCOPES = ["openid", "email", "profile"];

/**
* Authorization-server metadata advertised at `/.well-known/oauth-authorization-server`
* (a convenience proxy; modern clients discover the AS via the protected-resource doc).
Expand All @@ -54,10 +62,33 @@ export function buildOAuthMetadata(oauth: OAuthSettings): OAuthMetadata {
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
scopes_supported: ["openid", "email", "profile"],
// Same source as the protected-resource document, so the two can't contradict each
// other: an operator who sets OAUTH_SCOPES_SUPPORTED for a non-WorkOS IdP would
// otherwise still see `openid email profile` advertised here. Falls back to the
// historic default when unset, leaving existing deployments unchanged.
scopes_supported: advertisedScopes(oauth) ?? DEFAULT_ADVERTISED_SCOPES,
};
}

/**
* Scopes to advertise as `scopes_supported` in the protected-resource metadata
* (RFC 9728), or `undefined` when none are configured.
*
* `undefined` rather than `[]` is deliberate: `mcpAuthMetadataRouter` copies the value
* straight into the metadata object, and `JSON.stringify` drops an undefined property —
* so with nothing configured the document is byte-for-byte what it was before this
* option existed. An empty array would instead advertise `"scopes_supported": []`,
* which is a different (and misleading) statement.
*
* Why advertise at all: without `scopes_supported` a client has no way to know what to
* ask for and may omit `scope` from the authorization request entirely, which some IdPs
* reject outright (Microsoft Entra: `AADSTS900144: The request body must contain the
* following parameter: 'scope'`).
*/
export function advertisedScopes(oauth: OAuthSettings): string[] | undefined {
return oauth.scopesSupported?.length ? oauth.scopesSupported : undefined;
}

/** Network timeout for the userinfo lookup so a hung IdP can't block a request indefinitely. */
const USERINFO_TIMEOUT_MS = 10_000;

Expand Down
5 changes: 4 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mcpAuthMetadataRouter, McpServer, requireBearerAuth } from "skybridge/s
import { bearerAuthMiddleware } from "./auth.js";
import { ConfigError, describeCapabilities, loadConfig } from "./config.js";
import { LexwareClient } from "./lexware/client.js";
import { buildOAuthMetadata, createAccessTokenVerifier } from "./oauth.js";
import { advertisedScopes, buildOAuthMetadata, createAccessTokenVerifier } from "./oauth.js";
import { registerTools } from "./tools/index.js";

/** Base64 file uploads (upload-file / upload-voucher-file) travel inline in the JSON-RPC body. */
Expand Down Expand Up @@ -93,6 +93,9 @@ if (config.auth.mode === "oauth") {
mcpAuthMetadataRouter({
oauthMetadata: buildOAuthMetadata(oauth),
resourceServerUrl: new URL(oauth.resource),
// Undefined unless OAUTH_SCOPES_SUPPORTED is set, which keeps `scopes_supported`
// out of the protected-resource document exactly as before (see advertisedScopes).
scopesSupported: advertisedScopes(oauth),
}),
);
// RFC 9728: the protected-resource metadata path is the well-known segment
Expand Down
44 changes: 44 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe("loadConfig", () => {
resource: "https://mcp.example.com",
verifyAudience: true,
extraAudiences: [],
scopesSupported: [],
allowedEmailDomains: ["example.com", "example.org"],
authorizationEndpoint: "https://auth.example.com/oauth2/authorize",
tokenEndpoint: "https://auth.example.com/oauth2/token",
Expand Down Expand Up @@ -105,6 +106,49 @@ describe("loadConfig", () => {
});
});

it("parses OAUTH_SCOPES_SUPPORTED into a scope list (trimmed, blanks dropped)", () => {
const c = loadConfig({
LEXWARE_API_KEY: "k",
OAUTH_ISSUER: "https://auth.example.com",
SERVER_URL: "https://mcp.example.com",
OAUTH_SCOPES_SUPPORTED: "openid, email , api://abc/mcp.access, ",
} as NodeJS.ProcessEnv);
expect(c.auth).toMatchObject({
scopesSupported: ["openid", "email", "api://abc/mcp.access"],
});
});

it("accepts space-separated scopes (the form scopes appear in everywhere else in OAuth)", () => {
// A scope value can never contain a space (RFC 6749 3.3), so "openid email" must
// parse as two scopes, not one bogus scope named "openid email".
const c = loadConfig({
LEXWARE_API_KEY: "k",
OAUTH_ISSUER: "https://auth.example.com",
SERVER_URL: "https://mcp.example.com",
OAUTH_SCOPES_SUPPORTED: "openid email profile",
} as NodeJS.ProcessEnv);
expect(c.auth).toMatchObject({ scopesSupported: ["openid", "email", "profile"] });
});

it("accepts commas and whitespace mixed, including newlines", () => {
const c = loadConfig({
LEXWARE_API_KEY: "k",
OAUTH_ISSUER: "https://auth.example.com",
SERVER_URL: "https://mcp.example.com",
OAUTH_SCOPES_SUPPORTED: "openid,\n email profile,,",
} as NodeJS.ProcessEnv);
expect(c.auth).toMatchObject({ scopesSupported: ["openid", "email", "profile"] });
});

it("defaults OAUTH_SCOPES_SUPPORTED to an empty list (nothing advertised)", () => {
const c = loadConfig({
LEXWARE_API_KEY: "k",
OAUTH_ISSUER: "https://auth.example.com",
SERVER_URL: "https://mcp.example.com",
} as NodeJS.ProcessEnv);
expect(c.auth).toMatchObject({ scopesSupported: [] });
});

it("OAuth takes precedence over a static token", () => {
const c = loadConfig({ ...base(), OAUTH_ISSUER: "https://auth.example.com", SERVER_URL: "https://x.example.com" } as NodeJS.ProcessEnv);
expect(c.auth.mode).toBe("oauth");
Expand Down
78 changes: 78 additions & 0 deletions tests/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import express from "express";
import * as jose from "jose";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { beforeAll, describe, expect, it } from "vitest";
import {
advertisedScopes,
buildOAuthMetadata,
createAccessTokenVerifier,
isEmailDomainAllowed,
isEmailVerified,
Expand Down Expand Up @@ -207,3 +213,75 @@ describe("createAccessTokenVerifier", () => {
await expect(verify(token)).rejects.toThrow(/domain is not permitted/);
});
});

describe("advertisedScopes", () => {
it("is undefined when no scopes are configured", () => {
expect(advertisedScopes(settings())).toBeUndefined();
expect(advertisedScopes(settings({ scopesSupported: [] }))).toBeUndefined();
});

it("returns the configured scopes", () => {
expect(advertisedScopes(settings({ scopesSupported: ["openid", "email"] }))).toEqual([
"openid",
"email",
]);
});
});

/** Serve the protected-resource metadata the way server.ts mounts it, and read it back. */
async function protectedResourceDoc(oauth: OAuthSettings): Promise<Record<string, unknown>> {
const app = express();
app.use(
mcpAuthMetadataRouter({
oauthMetadata: buildOAuthMetadata(oauth),
resourceServerUrl: new URL(oauth.resource),
scopesSupported: advertisedScopes(oauth),
}),
);
const server = createServer(app);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address() as AddressInfo;
const res = await fetch(`http://127.0.0.1:${port}/.well-known/oauth-protected-resource`);
return (await res.json()) as Record<string, unknown>;
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}

describe("protected-resource metadata (RFC 9728)", () => {
it("omits scopes_supported entirely when nothing is configured", async () => {
const doc = await protectedResourceDoc(settings());
// Not just falsy — the key must be absent, i.e. the document is unchanged from
// before OAUTH_SCOPES_SUPPORTED existed.
expect(Object.keys(doc)).not.toContain("scopes_supported");
expect(doc).toMatchObject({
resource: "https://mcp.example.com/",
authorization_servers: [ISSUER],
});
});

it("advertises the configured scopes so clients know what to request", async () => {
const doc = await protectedResourceDoc(settings({ scopesSupported: ["openid", "email", "api://x/mcp.access"] }));
expect(doc.scopes_supported).toEqual(["openid", "email", "api://x/mcp.access"]);
});
});

describe("buildOAuthMetadata scopes_supported", () => {
it("keeps the historic default when OAUTH_SCOPES_SUPPORTED is unset", () => {
expect(buildOAuthMetadata(settings()).scopes_supported).toEqual(["openid", "email", "profile"]);
expect(buildOAuthMetadata(settings({ scopesSupported: [] })).scopes_supported).toEqual([
"openid",
"email",
"profile",
]);
});

it("uses the configured scopes so the AS and protected-resource docs cannot contradict", () => {
const oauth = settings({ scopesSupported: ["api://x/mcp.access"] });
// Both documents must name the same scopes; an operator on a non-WorkOS IdP would
// otherwise still see `openid email profile` advertised in the AS metadata.
expect(buildOAuthMetadata(oauth).scopes_supported).toEqual(["api://x/mcp.access"]);
expect(advertisedScopes(oauth)).toEqual(["api://x/mcp.access"]);
});
});