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
7 changed files with 7585 additions and 595 deletions
61
core/client/config-parse.ts
Normal file
61
core/client/config-parse.ts
Normal 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
67
core/client/config.ts
Normal 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
20
core/client/index.ts
Normal 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";
|
||||
118
core/client/node-persistence.ts
Normal file
118
core/client/node-persistence.ts
Normal 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
78
core/client/remote.ts
Normal 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
65
core/client/secrets.ts
Normal 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
36
core/client/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue