inspector/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts
Cliff Hall 7fecebed5e
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 (#1694)
* 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>
2026-07-16 16:52:53 -04:00

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
);
}