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:
parent
5ee347571f
commit
5de4e911e3
2 changed files with 7585 additions and 595 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
Loading…
Reference in a new issue