Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1625) Re-key the persisted OAuth store to (server, issuer) and add the SEP-837/2350/2352 authorization-hardening UX. The SDK v2 auth() drives the SEP-2352 flow off the provider's ctx.issuer; the Inspector's job is to honor it and round-trip the issuer stamp. SEP-2352 — issuer-bound credentials - ServerOAuthState nests issuer-bound credentials under byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with an activeIssuer pointer answering the transport's ctx-less per-request bearer read. - Fix a latent bug: OAuthStorageBase re-parsed tokens/client-info through OAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFC `issuer` field and silently defeat the SDK's discardIfIssuerMismatch. The store re-attaches the stamp from the byIssuer key. - BaseOAuthClientProvider threads ctx.issuer and implements discoveryState()/saveDiscoveryState() (activates the SDK's callback-leg AuthorizationServerMismatchError binding check) and invalidateCredentials(). - Lazy, migration-free upgrade: a legacy pre-1625 blob's top-level clientInformation/tokens are returned as an unkeyed fallback until the first issuer-stamped save promotes them into byIssuer. SEP-837 — application_type - DCR declares application_type: "native" (localhost Inspector). - ClientSettingsForm renders RegistrationRejectedError detail and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status. SEP-2350 — step-up scope union - Per-server onInsufficientScope (reauthorize | throw) setting wired ServerSettingsForm -> StoredMCPServer.oauth -> StreamableHTTPClientTransport. - StepUpAuthModal visualizes the prior-union-challenged scope set, tagging each scope `new` vs `already granted`. mcpAuth simplification - Delete the hand-rolled authorizeWithoutRefresh (mishandled issuer stamping); forward forceReauthorization/skipIssuerMetadataValidation to native SDK auth() so both the initial and step-up paths get SEP-2352 stamping. Vocabulary + docs - OAuthStep gains cimd_fetch / issuer_comparison / scope_step_up (recorded on a step-up redirect); the Network tab labels auth requests by OAuth phase. - v2_auth_hardening.md gains an as-built status section. Deferred to the era-aware card: the SdkError(EraNegotiationFailed) -> UnauthorizedError unwrap and finishAuth(URLSearchParams) form (only fire under non-legacy negotiation). Closes #1625 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address @claude review: issuer-stamp source gating + clear/activeIssuer + phase over-match - SEP-2352: stamp the issuer only when the credential actually came from the byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated slot alongside a surviving legacy field would otherwise stamp a legacy (possibly cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch. - Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer` (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion). - oauthNetworkPhase matches register/token/authorize as the final path segment (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer over-match. - Tests for all three. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
import type { ClientConfig } from "@inspector/core/client/types.js";
|
|
import {
|
|
getCimdClientMetadataUrlError,
|
|
isAbsoluteHttpUrl,
|
|
} from "@inspector/core/client/config-parse.js";
|
|
|
|
/** Field-level error message for an issuer that is not an http(s) URL. */
|
|
export const ISSUER_URL_ERROR =
|
|
"Must be an http(s) URL, like https://idp.example.com";
|
|
/** Field-level errors for a required field left blank while its feature is enabled. */
|
|
export const ISSUER_REQUIRED_ERROR =
|
|
"Issuer is required when enterprise IdP configuration is enabled";
|
|
export const CLIENT_ID_REQUIRED_ERROR =
|
|
"Client ID is required when enterprise IdP configuration is enabled";
|
|
export const CLIENT_METADATA_URL_REQUIRED_ERROR =
|
|
"Metadata document URL is required when CIMD is enabled";
|
|
|
|
/** Field-level validation errors for the client settings form. */
|
|
export interface ClientSettingsErrors {
|
|
issuer?: string;
|
|
clientId?: string;
|
|
clientMetadataUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* A Dynamic Client Registration rejection surfaced from the authorization
|
|
* server (SEP-837). Parsed from the SDK's `RegistrationRejectedError` — the
|
|
* RFC 7591 error JSON (`error` / `error_description`) plus the HTTP status — so
|
|
* the form can explain *why* registration failed (commonly a redirect-URI
|
|
* constraint that the `application_type: "native"` declaration is meant to
|
|
* satisfy) rather than showing a bare failure.
|
|
*/
|
|
export interface OAuthRegistrationError {
|
|
/** RFC 7591 `error` code (e.g. `invalid_redirect_uri`, `invalid_client_metadata`). */
|
|
error?: string;
|
|
/** RFC 7591 `error_description`, if the AS provided one. */
|
|
errorDescription?: string;
|
|
/** HTTP status returned by the registration endpoint. */
|
|
status?: number;
|
|
}
|
|
|
|
export interface ValidateClientSettingsOptions {
|
|
/**
|
|
* Also flag required fields left blank. Off by default so inline (as-you-type)
|
|
* validation only flags a field the user has actually filled in wrong; turned
|
|
* on when a save/close is attempted so an incomplete config is surfaced rather
|
|
* than silently dropped.
|
|
*/
|
|
requireComplete?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Validation for the client settings form. By default only flags fields the
|
|
* user has filled in wrong (a non-empty, non-http(s) issuer; a malformed CIMD
|
|
* metadata URL). With `requireComplete`, also flags the required fields of an
|
|
* enabled feature (EMA's issuer/clientId, CIMD's metadata URL) when they are
|
|
* blank — used at save/close time so an enabled-but-incomplete config never
|
|
* persists silently.
|
|
*/
|
|
export function validateClientSettings(
|
|
values: ClientSettingsFormValues,
|
|
options: ValidateClientSettingsOptions = {},
|
|
): ClientSettingsErrors {
|
|
const errors: ClientSettingsErrors = {};
|
|
|
|
if (values.emaEnabled) {
|
|
const issuer = values.issuer.trim();
|
|
if (issuer === "") {
|
|
if (options.requireComplete) errors.issuer = ISSUER_REQUIRED_ERROR;
|
|
} else if (!isAbsoluteHttpUrl(values.issuer)) {
|
|
errors.issuer = ISSUER_URL_ERROR;
|
|
}
|
|
|
|
if (options.requireComplete && values.clientId.trim() === "") {
|
|
errors.clientId = CLIENT_ID_REQUIRED_ERROR;
|
|
}
|
|
}
|
|
|
|
if (values.cimdEnabled) {
|
|
const url = values.clientMetadataUrl.trim();
|
|
if (url === "") {
|
|
if (options.requireComplete) {
|
|
errors.clientMetadataUrl = CLIENT_METADATA_URL_REQUIRED_ERROR;
|
|
}
|
|
} else {
|
|
const cimdError = getCimdClientMetadataUrlError(values.clientMetadataUrl);
|
|
if (cimdError) {
|
|
errors.clientMetadataUrl = cimdError;
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/** Form shape for install-level client settings (client.json). */
|
|
export interface ClientSettingsFormValues {
|
|
emaEnabled: boolean;
|
|
issuer: string;
|
|
clientId: string;
|
|
clientSecret: string;
|
|
cimdEnabled: boolean;
|
|
clientMetadataUrl: string;
|
|
}
|
|
|
|
export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = {
|
|
emaEnabled: false,
|
|
issuer: "",
|
|
clientId: "",
|
|
clientSecret: "",
|
|
cimdEnabled: false,
|
|
clientMetadataUrl: "",
|
|
};
|
|
|
|
export function clientConfigToFormValues(
|
|
config: ClientConfig,
|
|
): ClientSettingsFormValues {
|
|
const ema = config.enterpriseManagedAuth;
|
|
const idp = ema?.idp;
|
|
const cimd = config.cimd;
|
|
|
|
return {
|
|
emaEnabled: idp ? ema!.enabled !== false : false,
|
|
issuer: idp?.issuer ?? "",
|
|
clientId: idp?.clientId ?? "",
|
|
clientSecret: idp?.clientSecret ?? "",
|
|
cimdEnabled: cimd?.enabled === true,
|
|
clientMetadataUrl: cimd?.clientMetadataUrl ?? "",
|
|
};
|
|
}
|
|
|
|
function hasStoredIdpFields(values: ClientSettingsFormValues): boolean {
|
|
return (
|
|
values.issuer.trim() !== "" ||
|
|
values.clientId.trim() !== "" ||
|
|
values.clientSecret !== ""
|
|
);
|
|
}
|
|
|
|
/** Serialize the full dialog state. POST replaces client.json wholesale. */
|
|
export function formValuesToClientConfig(
|
|
values: ClientSettingsFormValues,
|
|
): ClientConfig {
|
|
const result: ClientConfig = {
|
|
cimd: {
|
|
enabled: values.cimdEnabled,
|
|
clientMetadataUrl: values.clientMetadataUrl.trim(),
|
|
},
|
|
};
|
|
|
|
if (hasStoredIdpFields(values) || values.emaEnabled) {
|
|
result.enterpriseManagedAuth = {
|
|
enabled: values.emaEnabled,
|
|
idp: {
|
|
issuer: values.issuer.trim(),
|
|
clientId: values.clientId.trim(),
|
|
clientSecret: values.clientSecret,
|
|
},
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Skip the debounced persist while an enabled feature's required fields are
|
|
* blank or invalid (EMA's issuer/clientId, CIMD's metadata URL). Defers to
|
|
* {@link validateClientSettings} in `requireComplete` mode so the persist gate
|
|
* and the close-time field errors can never drift: the same condition that
|
|
* blocks the write also surfaces the field-level errors that explain why.
|
|
*/
|
|
export function canPersistClientSettingsDraft(
|
|
values: ClientSettingsFormValues,
|
|
): boolean {
|
|
return (
|
|
Object.keys(validateClientSettings(values, { requireComplete: true }))
|
|
.length === 0
|
|
);
|
|
}
|