Skip to content
Closed
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 @@ -57,6 +57,14 @@ MCP_AUTH_TOKEN=
# parameter (Microsoft Entra fails with AADSTS900144).
# OAUTH_SCOPES_SUPPORTED=openid,email,profile
#
# Read the issuer's real metadata at startup and use it for BOTH the advertised endpoints AND
# token verification (jwks_uri, userinfo), rather than guessing from the WorkOS URL layout
# (wrong for e.g. Entra, whose authorize endpoint is /oauth2/v2.0/authorize). A document whose
# issuer does not match is discarded; discovered URLs must be https (or loopback http).
# Unreachable/failed issuer -> derived defaults + a warning; total fetch is bounded (~5s) and
# never blocks startup. The explicit overrides below always win over whatever discovery returns.
# OAUTH_DISCOVERY=true
#
# Overrides (defaults derived from OAUTH_ISSUER):
# OAUTH_JWKS_URL=https://your-tenant.authkit.app/oauth2/jwks
# OAUTH_USERINFO_URL=https://your-tenant.authkit.app/oauth2/userinfo
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.11]

### Fixed
- **The authorization-server document is now read from the issuer instead of guessed.**
`buildOAuthMetadata` fabricated every field: endpoints derived from the WorkOS URL layout
(`{issuer}/oauth2/*`) plus hardcoded `grant_types_supported`, `code_challenge_methods_supported`
and `scopes_supported`. For any IdP that doesn't share WorkOS's layout the result was simply
wrong — Microsoft Entra's authorize endpoint is `/oauth2/v2.0/authorize`, not the
`/v2.0/oauth2/authorize` we derived — and the fabricated values were presented to clients as
fact. This was the root cause behind the `registration_endpoint` bug fixed in 0.1.10.

At startup the server now fetches the issuer's own metadata (RFC 8414, falling back to OIDC
discovery, distinct URL layouts tried in order) and resolves each endpoint **once** —
explicit env override, then the discovered document, then the derived default. That single
resolved set drives **both** the advertised metadata **and** token verification, so what the
server tells clients (its `jwks_uri`, its endpoints) is exactly what it verifies against.

Safety properties, each covered by a test:
- A document whose `issuer` does not match the configured issuer is **discarded**, not merged
(trailing-slash-insensitive). This document decides where clients authenticate and which keys
we verify against, so accepting one that speaks for a different issuer would be a
redirect-hijack / key-substitution primitive.
- `issuer` is never taken from the document — it must match the `iss` claim byte-for-byte.
- Every discovered URL is validated (`https`, or `http` only on loopback) before it is
advertised or used; a downgraded or malformed value is ignored in favour of the derived
default. This matters most for `jwks_uri`, which now feeds signature verification.
- A successful discovery is authoritative about DCR: if it omits `registration_endpoint`, the
field is omitted — the derived guess is used only when discovery did not run or failed, so
the 0.1.10 fix cannot be silently undone.
- Any failure (unreachable, timeout, non-JSON, HTTP error) falls back to the derived defaults
and logs a warning; discovery can improve the document but never prevent startup. Total
discovery time is bounded (~5s) across all candidate URLs, not per URL.
- Explicit overrides still win, including `OAUTH_REGISTRATION_ENDPOINT=none`, `OAUTH_JWKS_URL`
and `OAUTH_USERINFO_URL`.

Disable with `OAUTH_DISCOVERY=false`.

## [0.1.10]

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ LEXWARE_API_KEY=... MCP_AUTH_TOKEN=... npm start
| `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_DISCOVERY` | `true` | Read the issuer's own metadata at startup (RFC 8414, falling back to OIDC discovery) and use it for **both** the advertised endpoints **and** token verification (`jwks_uri`, userinfo), instead of guessing from the WorkOS URL layout. Each endpoint resolves as _explicit override → discovered → derived default_. A document whose `issuer` doesn't match is discarded; discovered URLs must be `https` (or loopback `http`). Any failure falls back to the derived defaults and logs a warning — total fetch time is bounded (~5s) and never blocks startup. Set `false` to skip the fetch |
| `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`, Entra: `/oauth2/v2.0/authorize`). Set `OAUTH_REGISTRATION_ENDPOINT=none` if your issuer does **not** support Dynamic Client Registration — the field is optional in RFC 8414, and advertising an endpoint that rejects every request makes clients attempt DCR and fail rather than use a pre-registered client |
| `MCP_AUTH_TOKEN` | — (**required**¹) | Static bearer token clients send to reach `/mcp` (used when OAuth is off) |
| `MCP_ALLOW_UNAUTHENTICATED` | `false` | Opt out of auth (trusted local use only — bind to localhost/private network) |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lexware-mcp",
"version": "0.1.10",
"version": "0.1.11",
"private": false,
"license": "MIT",
"description": "Open-source, self-hostable MCP server for the Lexware Office API",
Expand Down
31 changes: 31 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ export type AuthConfig =
* registration and fail instead of falling back to a pre-registered client.
*/
registrationEndpoint: string | undefined;
/**
* Fetch the issuer's own authorization-server metadata at startup and advertise
* what it actually says, instead of guessing from the WorkOS URL layout. Falls
* back to the derived defaults if the issuer is unreachable or the document is
* unusable, so a network blip can never stop the server booting.
* Disable with OAUTH_DISCOVERY=false.
*/
discovery: boolean;
/**
* Which endpoints the operator pinned via env. These beat anything discovery
* returns — an explicit override exists precisely to correct a wrong document.
*/
explicitEndpoints: {
authorization: boolean;
token: boolean;
registration: boolean;
jwks: boolean;
userinfo: boolean;
};
}
| { mode: "static"; token: string }
| { mode: "none" };
Expand Down Expand Up @@ -234,10 +253,22 @@ function resolveAuth(env: NodeJS.ProcessEnv): AuthConfig {
`${issuerBase}/oauth2/register`,
"OAUTH_REGISTRATION_ENDPOINT",
);
const discovery = parseBool(env.OAUTH_DISCOVERY, true);
const explicitEndpoints = {
authorization: Boolean(env.OAUTH_AUTHORIZATION_ENDPOINT?.trim()),
token: Boolean(env.OAUTH_TOKEN_ENDPOINT?.trim()),
// `none` counts as explicit: the operator deliberately said "no DCR", and a
// discovered registration_endpoint must not silently re-enable advertising it.
registration: Boolean(registrationRaw),
jwks: Boolean(env.OAUTH_JWKS_URL?.trim()),
userinfo: Boolean(env.OAUTH_USERINFO_URL?.trim()),
};
return {
mode: "oauth",
issuer,
jwksUrl,
discovery,
explicitEndpoints,
resource,
verifyAudience,
extraAudiences,
Expand Down
195 changes: 181 additions & 14 deletions src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export interface OAuthSettings {
* `${issuer}/oauth2/register`; `undefined` omits the field (see AuthConfig).
*/
registrationEndpoint?: string;
/** Endpoints pinned via env; these beat discovery (see AuthConfig). */
explicitEndpoints?: {
authorization: boolean;
token: boolean;
registration: boolean;
jwks: boolean;
userinfo: boolean;
};
}

/** True when `email`'s domain is in `allowed` (case-insensitive). Pure; unit-tested. */
Expand All @@ -48,30 +56,189 @@ export function isEmailDomainAllowed(email: string | undefined, allowed: string[
*/
const DEFAULT_ADVERTISED_SCOPES = ["openid", "email", "profile"];

/** Bound TOTAL discovery time so an unreachable issuer can't stall startup. */
const DISCOVERY_TIMEOUT_MS = 5_000;

/**
* The issuer's metadata as fetched. `userinfo_endpoint` is an OIDC field absent from
* the RFC 8414 `OAuthMetadata` type but present in an openid-configuration document; we
* use it for the email-domain fallback, so it must be readable here.
*/
export type DiscoveredMetadata = Partial<OAuthMetadata> & { userinfo_endpoint?: string };

/**
* Accept a discovered endpoint URL only when it is safe to advertise AND to use:
* `https` everywhere, `http` only for loopback (local mocks/tests). A discovered value
* that fails this — an `http://` downgrade, a `data:`/`file:` URI, a malformed string —
* is rejected, so we fall back to the configured/derived endpoint instead of trusting it.
*
* Load-bearing for `jwks_uri`: that URL now drives signature verification, so an
* unvalidated one would be a downgrade path straight into the auth check.
*/
export function safeDiscoveredUrl(value: unknown): string | undefined {
if (typeof value !== "string" || value === "") return undefined;
let url: URL;
try {
url = new URL(value);
} catch {
return undefined;
}
if (url.protocol === "https:") return value;
const loopback =
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "[::1]" ||
url.hostname === "::1";
return url.protocol === "http:" && loopback ? value : undefined;
}

/**
* Candidate well-known URLs for an issuer's metadata, in preference order and
* de-duplicated. Providers disagree on the layout:
*
* 1. RFC 8414 — well-known segment inserted *before* the issuer path.
* 2. OIDC Discovery — well-known segment *appended* (Entra, Auth0, Keycloak).
* 3. RFC 8414 spelling of the OIDC document, for providers that only publish that.
*
* For a path-less issuer (1) and (3) are byte-identical; the dedupe stops us burning
* the timeout budget on the same URL twice.
*/
export function discoveryUrls(issuer: string): string[] {
const u = new URL(issuer);
const path = u.pathname.replace(/\/+$/, "");
const base = u.origin;
return [
...new Set([
`${base}/.well-known/oauth-authorization-server${path}`,
`${base}${path}/.well-known/openid-configuration`,
`${base}/.well-known/openid-configuration${path}`,
]),
];
}

/** Trailing-slash-insensitive issuer comparison; some issuers' canonical form ends in "/". */
function sameIssuer(a: string, b: string): boolean {
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
}

/**
* Fetch the issuer's real metadata document.
*
* Returns `undefined` on any failure — unreachable issuer, non-JSON body, timeout,
* HTTP error, or an `issuer` claim that doesn't match. The caller then keeps the
* configured/derived defaults, so discovery can only ever *improve* what we advertise
* and verify against, and can never prevent startup.
*
* Two hard rules, both security controls rather than sanity checks:
* - The document's `issuer` MUST match the configured issuer. This document decides
* where clients authenticate and (now) which keys we verify against, so one that
* speaks for a different issuer is discarded, not merged.
* - Total time across all candidate URLs is bounded by {@link DISCOVERY_TIMEOUT_MS} —
* a hanging issuer delays startup by at most that once, not once per URL.
*/
export async function discoverAuthorizationServerMetadata(
issuer: string,
deps: VerifierDeps & { now?: () => number } = {},
): Promise<DiscoveredMetadata | undefined> {
const fetchFn = deps.fetchFn ?? fetch;
const now = deps.now ?? Date.now;
const deadline = now() + DISCOVERY_TIMEOUT_MS;
for (const url of discoveryUrls(issuer)) {
const remaining = deadline - now();
if (remaining <= 0) break;
try {
const res = await fetchFn(url, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(remaining),
});
if (!res.ok) continue;
const doc = (await res.json()) as DiscoveredMetadata;
if (typeof doc.issuer !== "string" || !sameIssuer(doc.issuer, issuer)) continue;
return doc;
} catch {
// Try the next layout; a total failure just means we keep the defaults.
}
}
return undefined;
}

/**
* Resolve every OAuth endpoint to a single effective value, used for BOTH the advertised
* metadata and the actual token verification, so the two can never disagree. Precedence
* per field:
*
* explicit env override > the issuer's discovered document > derived default
*
* Explicit wins because an override exists to correct a wrong/absent document. Discovered
* URLs are validated ({@link safeDiscoveredUrl}) before use — a downgraded or malformed
* value is ignored in favour of the derived default.
*
* `registrationEndpoint` is special: when discovery SUCCEEDED, the issuer's document is
* authoritative about whether DCR exists, so an ABSENT `registration_endpoint` means
* "omit it" — we must NOT fall back to the derived guess (that would re-advertise a
* broken endpoint, the bug 0.1.10 fixed). The derived guess applies only when discovery
* did not run or failed (`discovered === undefined`).
*/
export function resolveOAuthEndpoints(
oauth: OAuthSettings,
discovered?: DiscoveredMetadata,
): OAuthSettings {
const ex = oauth.explicitEndpoints;
const resolve = (explicit: boolean | undefined, discoveredUrl: unknown, derived: string): string =>
explicit ? derived : (safeDiscoveredUrl(discoveredUrl) ?? derived);

const registrationEndpoint = ex?.registration
? oauth.registrationEndpoint // explicit (incl. `none` → undefined) always wins
: discovered
? safeDiscoveredUrl(discovered.registration_endpoint) // authoritative: absent/invalid → undefined
: oauth.registrationEndpoint; // no discovery → derived guess

return {
...oauth,
authorizationEndpoint: resolve(
ex?.authorization,
discovered?.authorization_endpoint,
oauth.authorizationEndpoint,
),
tokenEndpoint: resolve(ex?.token, discovered?.token_endpoint, oauth.tokenEndpoint),
jwksUrl: resolve(ex?.jwks, discovered?.jwks_uri, oauth.jwksUrl),
userinfoUrl: resolve(ex?.userinfo, discovered?.userinfo_endpoint, oauth.userinfoUrl),
registrationEndpoint,
};
}

/**
* Authorization-server metadata advertised at `/.well-known/oauth-authorization-server`
* (a convenience proxy; modern clients discover the AS via the protected-resource doc).
*
* Endpoints come straight from `oauth`, which the caller has already passed through
* {@link resolveOAuthEndpoints} — so the document advertises exactly the endpoints the
* server verifies against. `discovered` supplies only the capability arrays the server
* itself doesn't consume (grant types, response types, PKCE methods, scopes).
*/
export function buildOAuthMetadata(oauth: OAuthSettings): OAuthMetadata {
// `issuer` must be exact. The endpoints default to the WorkOS-AuthKit layout but
// are overridable (config), so non-WorkOS issuers (Auth0 uses /authorize and
// /oauth/token, Keycloak uses /protocol/openid-connect/*) advertise correctly.
export function buildOAuthMetadata(
oauth: OAuthSettings,
discovered?: DiscoveredMetadata,
): OAuthMetadata {
return {
// `issuer` is never taken from the document — it must match the `iss` claim exactly.
issuer: oauth.issuer,
authorization_endpoint: oauth.authorizationEndpoint,
token_endpoint: oauth.tokenEndpoint,
// Omitted entirely when not configured: `registration_endpoint` is optional in
// RFC 8414, and advertising one the issuer will reject is worse than saying nothing.
// Omitted when neither configured nor discovered: `registration_endpoint` is optional
// in RFC 8414, and advertising one the issuer will reject is worse than saying nothing.
...(oauth.registrationEndpoint ? { registration_endpoint: oauth.registrationEndpoint } : {}),
jwks_uri: oauth.jwksUrl,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
// 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,
response_types_supported: discovered?.response_types_supported ?? ["code"],
grant_types_supported: discovered?.grant_types_supported ?? [
"authorization_code",
"refresh_token",
],
code_challenge_methods_supported: discovered?.code_challenge_methods_supported ?? ["S256"],
// OAUTH_SCOPES_SUPPORTED wins so this document and the protected-resource document
// can't contradict each other; then the issuer's real list; then the historic default.
scopes_supported:
advertisedScopes(oauth) ?? discovered?.scopes_supported ?? DEFAULT_ADVERTISED_SCOPES,
};
}

Expand Down
Loading