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
7 changed files with 7585 additions and 595 deletions

View file

@ -0,0 +1,61 @@
/**
* Browser-safe client.json parse/validate (no Node file I/O).
*/
import { z } from "zod";
import type { ClientConfig } from "./types.js";
const HttpUrlStringSchema = z.string().min(1).superRefine((val, ctx) => {
const trimmed = val.trim();
if (!URL.canParse(trimmed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid URL: "${trimmed}" — must be an absolute URL (e.g. https://idp.example.com)`,
});
}
});
const EnterpriseManagedAuthIdpConfigSchema = z.object({
issuer: HttpUrlStringSchema,
clientId: z.string().min(1),
clientSecret: z.string().optional(),
});
const ClientConfigSchema = z.object({
enterpriseManagedAuth: z
.object({
enabled: z.boolean().optional(),
idp: EnterpriseManagedAuthIdpConfigSchema,
})
.optional(),
});
/**
* Parse and validate unknown JSON into {@link ClientConfig}.
* @throws {z.ZodError} when shape is invalid
*/
export function parseClientConfig(raw: unknown): ClientConfig {
return ClientConfigSchema.parse(raw);
}
/** Canonical JSON serialization for client.json (matches backend store-io format). */
export function serializeClientConfig(config: ClientConfig): string {
return JSON.stringify(config, null, 2);
}
/** Human-readable message for a failed client.json load (parse or transport). */
export function formatClientConfigLoadError(error: unknown): string {
if (error instanceof z.ZodError) {
const issue = error.issues[0];
if (issue) {
const field =
issue.path.length > 0 ? issue.path.map(String).join(".") : "config";
return `${field}: ${issue.message}`;
}
return "Invalid client.json shape";
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}

67
core/client/config.ts Normal file
View file

@ -0,0 +1,67 @@
/**
* Load/save/validate install-level client config (client.json).
*/
import type { SecretStore } from "../auth/node/secret-store.js";
import {
getDefaultStorageDir,
getStoreFilePath,
parseStore,
readStoreFile,
serializeStore,
writeStoreFile,
} from "../storage/store-io.js";
import { parseClientConfig } from "./config-parse.js";
import {
readClientConfigStore,
writeClientConfigStore,
} from "./node-persistence.js";
import type { ClientConfig } from "./types.js";
export { parseClientConfig, serializeClientConfig } from "./config-parse.js";
/** Default path: ~/.mcp-inspector/storage/client.json */
export function getClientConfigFilePath(customPath?: string): string {
return customPath ?? getStoreFilePath(getDefaultStorageDir(), "client");
}
/**
* Read client.json from disk. Returns `{}` when the file is absent.
* When `secretStore` is provided, IdP clientSecret is read from the keychain.
*/
export async function loadClientConfig(options?: {
filePath?: string;
secretStore?: SecretStore;
}): Promise<ClientConfig> {
const filePath = getClientConfigFilePath(options?.filePath);
if (options?.secretStore) {
return readClientConfigStore(filePath, options.secretStore);
}
const raw = await readStoreFile(filePath);
if (raw === null) {
return {};
}
return parseClientConfig(parseStore(raw));
}
/** Write client.json atomically (mode 0o600). */
export async function saveClientConfig(
config: ClientConfig,
options?: { filePath?: string; secretStore?: SecretStore },
): Promise<void> {
const filePath = getClientConfigFilePath(options?.filePath);
if (options?.secretStore) {
await writeClientConfigStore(filePath, config, options.secretStore);
return;
}
const validated = parseClientConfig(config);
await writeStoreFile(filePath, serializeStore(validated));
}
export {
deleteClientConfigStore,
readClientConfigStore,
writeClientConfigStore,
} from "./node-persistence.js";

20
core/client/index.ts Normal file
View file

@ -0,0 +1,20 @@
export type {
ClientConfig,
EnterpriseManagedAuthIdpConfig,
} from "./types.js";
export {
getActiveEnterpriseManagedAuthIdp,
isEnterpriseManagedAuthEnabled,
} from "./types.js";
export {
getClientConfigFilePath,
loadClientConfig,
parseClientConfig,
saveClientConfig,
serializeClientConfig,
} from "./config.js";
export {
loadClientConfigRemote,
saveClientConfigRemote,
type RemoteClientConfigOptions,
} from "./remote.js";

View file

@ -0,0 +1,118 @@
/**
* Node-only client.json persistence with OS keychain for IdP clientSecret.
*/
import {
KeychainUnavailableError,
type SecretStore,
} from "../auth/node/secret-store.js";
import { SECRET_FIELD_IDP_CLIENT_SECRET } from "../auth/secret-fields.js";
import {
deleteStoreFile,
parseStore,
readStoreFile,
serializeStore,
writeStoreFile,
} from "../storage/store-io.js";
import { parseClientConfig } from "./config-parse.js";
import type { ClientConfig } from "./types.js";
import {
CLIENT_KEYCHAIN_ID,
extractSecretsFromClientConfig,
hasClientPlaintextSecret,
mergeSecretsIntoClientConfig,
} from "./secrets.js";
async function readIdpSecretFromKeychain(
secretStore: SecretStore,
): Promise<Record<string, string>> {
const secret = await secretStore.get(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
);
if (!secret) return {};
return { [SECRET_FIELD_IDP_CLIENT_SECRET]: secret };
}
async function migrateClientPlaintextSecret(
filePath: string,
config: ClientConfig,
secretStore: SecretStore,
): Promise<ClientConfig> {
const { stripped, secrets } = extractSecretsFromClientConfig(config);
const value = secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
if (!value) return config;
try {
const existing = await secretStore.get(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
);
if (existing === null) {
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
value,
);
}
await writeStoreFile(filePath, serializeStore(stripped));
return stripped;
} catch (err) {
if (err instanceof KeychainUnavailableError) {
return config;
}
throw err;
}
}
/** Read client.json from disk and rehydrate IdP clientSecret from the keychain. */
export async function readClientConfigStore(
filePath: string,
secretStore: SecretStore,
): Promise<ClientConfig> {
const raw = await readStoreFile(filePath);
if (raw === null) {
return {};
}
let config = parseClientConfig(parseStore(raw));
if (hasClientPlaintextSecret(config)) {
config = await migrateClientPlaintextSecret(filePath, config, secretStore);
}
const secrets = await readIdpSecretFromKeychain(secretStore);
return mergeSecretsIntoClientConfig(config, secrets);
}
/** Validate, strip IdP clientSecret to keychain, and write client.json. */
export async function writeClientConfigStore(
filePath: string,
body: unknown,
secretStore: SecretStore,
): Promise<void> {
const validated = parseClientConfig(body);
const { stripped, secrets } = extractSecretsFromClientConfig(validated);
const idpSecret = secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
if (idpSecret) {
await secretStore.set(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
idpSecret,
);
} else {
await secretStore.delete(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
);
}
await writeStoreFile(filePath, serializeStore(stripped));
}
/** Remove client.json and the install-level IdP secret from the keychain. */
export async function deleteClientConfigStore(
filePath: string,
secretStore: SecretStore,
): Promise<void> {
await deleteStoreFile(filePath);
await secretStore.delete(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET);
}

78
core/client/remote.ts Normal file
View file

@ -0,0 +1,78 @@
/**
* Remote HTTP load/save for client.json via /api/storage/client.
*/
import {
parseClientConfig,
serializeClientConfig,
} from "./config-parse.js";
import type { ClientConfig } from "./types.js";
export interface RemoteClientConfigOptions {
/** Base URL of the remote server (e.g. http://localhost:3000) */
baseUrl: string;
/** Optional auth token for x-mcp-remote-auth header */
authToken?: string;
fetchFn?: typeof fetch;
}
/**
* GET /api/storage/client — returns `{}` when store is empty or 404.
*/
export async function loadClientConfigRemote(
options: RemoteClientConfigOptions,
): Promise<ClientConfig> {
const baseUrl = options.baseUrl.replace(/\/$/, "");
const fetchFn = options.fetchFn ?? globalThis.fetch;
const headers: Record<string, string> = {};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/storage/client`, {
method: "GET",
headers,
});
if (!res.ok) {
if (res.status === 404) {
return {};
}
throw new Error(`Failed to read client config: ${res.status}`);
}
const store = await res.json();
if (!store || typeof store !== "object" || Object.keys(store).length === 0) {
return {};
}
return parseClientConfig(store);
}
/**
* POST /api/storage/client — replaces the stored client config blob.
*/
export async function saveClientConfigRemote(
config: ClientConfig,
options: RemoteClientConfigOptions,
): Promise<void> {
const validated = parseClientConfig(config);
const baseUrl = options.baseUrl.replace(/\/$/, "");
const fetchFn = options.fetchFn ?? globalThis.fetch;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/storage/client`, {
method: "POST",
headers,
body: serializeClientConfig(validated),
});
if (!res.ok) {
throw new Error(`Failed to write client config: ${res.status}`);
}
}

65
core/client/secrets.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* Strip/merge install-level IdP client secrets for client.json ↔ keychain.
* Browser-safe — no Node keychain imports.
*/
import { SECRET_FIELD_IDP_CLIENT_SECRET } from "../auth/secret-fields.js";
import type { ClientConfig } from "./types.js";
/** Keychain namespace for install-level client config (not a server catalog id). */
export const CLIENT_KEYCHAIN_ID = "client";
export function hasClientPlaintextSecret(config: ClientConfig): boolean {
const secret = config.enterpriseManagedAuth?.idp?.clientSecret;
return typeof secret === "string" && secret.length > 0;
}
/**
* Lift IdP `clientSecret` off the config blob. The stripped shape is what
* lands on disk; the secret map is written to the OS keychain.
*/
export function extractSecretsFromClientConfig(config: ClientConfig): {
stripped: ClientConfig;
secrets: Record<string, string>;
} {
const secrets: Record<string, string> = {};
const ema = config.enterpriseManagedAuth;
if (!ema?.idp?.clientSecret) {
return { stripped: config, secrets };
}
const { clientSecret, ...idpRest } = ema.idp;
secrets[SECRET_FIELD_IDP_CLIENT_SECRET] = clientSecret;
return {
stripped: {
...config,
enterpriseManagedAuth: {
...ema,
idp: idpRest,
},
},
secrets,
};
}
/** Merge keychain secrets back into a stripped on-disk client config. */
export function mergeSecretsIntoClientConfig(
config: ClientConfig,
secrets: Record<string, string>,
): ClientConfig {
const idpSecret = secrets[SECRET_FIELD_IDP_CLIENT_SECRET];
const ema = config.enterpriseManagedAuth;
if (!idpSecret || !ema?.idp) {
return config;
}
return {
...config,
enterpriseManagedAuth: {
...ema,
idp: {
...ema.idp,
clientSecret: idpSecret,
},
},
};
}

36
core/client/types.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* Install-level client configuration (IdP / EMA settings, later client identity).
* Persisted in ~/.mcp-inspector/storage/client.json via /api/storage/client.
*/
/** OIDC client credentials for the enterprise IdP (legs 1–2). */
export interface EnterpriseManagedAuthIdpConfig {
issuer: string;
clientId: string;
/** Present after keychain merge; omitted from on-disk client.json. */
clientSecret?: string;
}
export interface ClientConfig {
enterpriseManagedAuth?: {
/** When false, IdP credentials are kept but EMA is inactive install-wide. */
enabled?: boolean;
idp: EnterpriseManagedAuthIdpConfig;
};
}
/** True when install-level EMA IdP config is active (not just stored). */
export function isEnterpriseManagedAuthEnabled(
config: ClientConfig,
): boolean {
const ema = config.enterpriseManagedAuth;
if (!ema?.idp) return false;
return ema.enabled !== false;
}
export function getActiveEnterpriseManagedAuthIdp(
config: ClientConfig,
): EnterpriseManagedAuthIdpConfig | undefined {
if (!isEnterpriseManagedAuthEnabled(config)) return undefined;
return config.enterpriseManagedAuth!.idp;
}