feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 7585 additions and 595 deletions

View file

@ -59,7 +59,14 @@ import {
KeyringSecretStore,
type SecretStore,
} from "../../../auth/node/secret-store.js";
import {
deleteClientConfigStore,
readClientConfigStore,
writeClientConfigStore,
} from "../../../client/node-persistence.js";
import { formatClientConfigLoadError } from "../../../client/config-parse.js";
import { envSecretField } from "../../../auth/secret-fields.js";
import { ZodError } from "zod";
/**
* Shape of the initial config returned by GET /api/config (defaults for client).
@ -808,6 +815,11 @@ export function createRemoteApp(
const filePath = getStoreFilePath(storageDir, storeId);
try {
if (storeId === "client") {
const config = await readClientConfigStore(filePath, secretStore);
return c.json(config);
}
const raw = await readStoreFile(filePath);
if (raw === null) {
return c.json({}, 200);
@ -836,10 +848,25 @@ export function createRemoteApp(
const filePath = getStoreFilePath(storageDir, storeId);
try {
if (storeId === "client") {
await writeClientConfigStore(filePath, body, secretStore);
return c.json({ ok: true });
}
const jsonData = serializeStore(body);
await writeStoreFile(filePath, jsonData);
return c.json({ ok: true });
} catch (error) {
// A malformed client.json body fails `parseClientConfig` with a ZodError
// — that's a client error (bad request), not a server failure. Return 400
// with the same human-readable formatting the load path uses, rather than
// letting it fall through to the generic 500 below.
if (error instanceof ZodError) {
return c.json({ error: formatClientConfigLoadError(error) }, 400);
}
const keychainResp =
storeId === "client" ? keychainErrorResponse(c, error) : undefined;
if (keychainResp) return keychainResp;
const msg = error instanceof Error ? error.message : String(error);
return c.json({ error: `Failed to write store: ${msg}` }, 500);
}
@ -854,6 +881,11 @@ export function createRemoteApp(
const filePath = getStoreFilePath(storageDir, storeId);
try {
if (storeId === "client") {
await deleteClientConfigStore(filePath, secretStore);
return c.json({ ok: true });
}
await deleteStoreFile(filePath);
return c.json({ ok: true });
} catch (error) {
@ -910,12 +942,23 @@ export function createRemoteApp(
};
const isOauthObject = (
v: unknown,
): v is { clientId?: string; clientSecret?: string; scopes?: string } => {
): v is {
clientId?: string;
clientSecret?: string;
scopes?: string;
enterpriseManaged?: boolean;
} => {
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
const o = v as Record<string, unknown>;
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
if (o[k] !== undefined && typeof o[k] !== "string") return false;
}
if (
o.enterpriseManaged !== undefined &&
typeof o.enterpriseManaged !== "boolean"
) {
return false;
}
return true;
};
// A roots array: each entry must have a string `uri` and, when present, a
@ -1021,7 +1064,7 @@ export function createRemoteApp(
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
logWarn(
{ route: "/api/servers", id, droppedKey: "oauth" },
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes? }`.",
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, enterpriseManaged? }`.",
);
delete valObj.oauth;
}
@ -1260,6 +1303,15 @@ export function createRemoteApp(
return { ok: false, error: `settings.${optional} must be a string` };
}
}
if (
obj.enterpriseManaged !== undefined &&
typeof obj.enterpriseManaged !== "boolean"
) {
return {
ok: false,
error: "settings.enterpriseManaged must be a boolean",
};
}
// roots is optional on the wire (older clients won't send it); when
// present it must be an array of `{ uri, name? }`. Reuses the same
// module-scope `isRootArray` guard the read path applies in
@ -1319,6 +1371,9 @@ export function createRemoteApp(
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
value.oauthScopes = obj.oauthScopes;
}
if (obj.enterpriseManaged === true) {
value.enterpriseManaged = true;
}
// Empty cwd coerces to absent on the value (matching the read side); the
// write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted"
// (preserve) via `cwdProvided` below rather than the value alone.
@ -1524,6 +1579,27 @@ export function createRemoteApp(
return obsolete;
};
/**
* Merge keychain secrets for a server-id rename. PUT payload values win
* over the old id's keychain entries; keychain fills fields missing from
* the payload (e.g. config-modal rename that does not re-send stripped
* secrets). Filtered to the new on-disk entry's expected fields so env
* keys removed during the rename are not carried over.
*/
const mergeRenameKeychainSecrets = (
stripped: StoredMCPServer,
keychainSecrets: Record<string, string>,
nextSecrets: Record<string, string>,
): Record<string, string> => {
const allowedFields = new Set(expectedSecretFields(stripped));
const merged = { ...keychainSecrets, ...nextSecrets };
const out: Record<string, string> = {};
for (const [field, value] of Object.entries(merged)) {
if (allowedFields.has(field)) out[field] = value;
}
return out;
};
// Parallel for symmetry with `readKeychainEntriesFor` /
// `writeKeychainEntriesFor`: distinct (id, field) deletes have no
// ordering requirement, and `secretStore.delete` is already a silent
@ -1977,7 +2053,17 @@ export function createRemoteApp(
// write leaves orphan keychain entries — recoverable on the
// next reconcile or `deleteAllForServer` sweep.
if (newId !== originalId) {
await writeKeychainEntriesFor(newId, secrets);
const previousFields = expectedSecretFields(existing);
const keychainSecrets = await readKeychainEntriesFor(
originalId,
previousFields,
);
const secretsToWrite = mergeRenameKeychainSecrets(
stripped,
keychainSecrets,
secrets,
);
await writeKeychainEntriesFor(newId, secretsToWrite);
await writeMcpAndTrackMtime(serializeStore(next));
await secretStore.deleteAllForServer(originalId);
} else {

View file

@ -1,13 +1,22 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type {
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { RemoteConnectRequest } from "../types.js";
const REMOTE_OAUTH_STUB_METADATA: OAuthClientMetadata = {
redirect_uris: [],
scope: "",
};
/**
* Simple OAuth client provider that just returns tokens.
* Used by the remote server to inject Bearer tokens into transport requests.
* The other OAuthClientProvider methods are required by the interface but never
* exercised in the remote-server path (the SDK only calls `tokens()` for Bearer
* injection); they are kept as no-op stubs.
*
* The SDK may invoke {@link auth} on 401; stubs must satisfy the full provider
* surface so that path fails with a clear error instead of throwing on
* undefined `clientMetadata.scope`.
*/
export function createTokenAuthProvider(
tokens: RemoteConnectRequest["oauthTokens"],
@ -15,6 +24,12 @@ export function createTokenAuthProvider(
if (!tokens) return undefined;
return {
get clientMetadata(): OAuthClientMetadata {
return REMOTE_OAUTH_STUB_METADATA;
},
get redirectUrl(): string {
return "";
},
async tokens(): Promise<OAuthTokens | undefined> {
return tokens as OAuthTokens;
},
@ -33,8 +48,10 @@ export function createTokenAuthProvider(
clear() {
// No-op
},
redirectToAuthorization() {
// No-op
async redirectToAuthorization() {
throw new Error(
"OAuth re-authorization must be performed in the Inspector web client (remote server cannot complete OAuth flows)",
);
},
state() {
return "";