inspector/core/auth/store.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

164 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* In-memory OAuth state store (servers + IdP sessions).
* Persisted via `OAuthStorageBase` + `OAuthPersistBackend` backends.
*/
import type {
OAuthClientInformation,
OAuthTokens,
OAuthMetadata,
OAuthDiscoveryState,
} from "@modelcontextprotocol/client";
import type {
IdpSessionState,
OAuthClientRegistrationKind,
} from "./storage.js";
import type { OAuthPersistSnapshot } from "./oauth-persist.js";
/**
* OAuth credentials bound to a single authorization-server `issuer` (SEP-2352).
*
* Client identifiers are unique to the AS that issued them (RFC 6749 §2.2), and
* tokens minted by one AS must never be presented to another. The SDK v2 stamps
* `issuer` on the objects it saves and passes `ctx.issuer` on every read/write;
* this record is how the Inspector keys that state per authorization server so a
* resource that migrates between ASes re-registers/re-authorizes cleanly instead
* of reusing mismatched credentials.
*/
export interface IssuerBoundOAuthState {
clientInformation?: OAuthClientInformation;
/** Set when {@link clientInformation} is saved — DCR vs CIMD. */
clientRegistrationKind?: OAuthClientRegistrationKind;
tokens?: OAuthTokens;
}
/**
* OAuth state for a single server.
*
* Issuer-bound credentials (client info + tokens) live under {@link byIssuer},
* keyed by AS `issuer` (SEP-2352). Per-server / per-flow state (code verifier,
* discovered metadata, discovery state, static client info, EMA marker, and the
* requested-scope seed) is issuer-independent and stays at the top level.
*
* The bare top-level {@link clientInformation} / {@link tokens} fields are the
* **legacy unkeyed fallback**: a pre-1625 snapshot deserializes with credentials
* here and no `issuer` stamp. Reads fall back to them when {@link byIssuer} has
* no entry; the first issuer-stamped save promotes them into {@link byIssuer} and
* clears the fallback (lazy migration — see `OAuthStorageBase`). New saves never
* write the bare fields.
*/
export interface ServerOAuthState {
/** Per-issuer credentials (SEP-2352). */
byIssuer?: Record<string, IssuerBoundOAuthState>;
/** Most-recently-saved issuer — answers ctx-less reads (per-request bearer token). */
activeIssuer?: string;
preregisteredClientInformation?: OAuthClientInformation;
codeVerifier?: string;
scope?: string;
serverMetadata?: OAuthMetadata;
/** RFC 9728/8414 discovery cache; persisted alongside {@link codeVerifier} (SEP-2352 callback-leg binding). */
discoveryState?: OAuthDiscoveryState;
/** Set when resource tokens were minted via EMA (legs 2–3). */
enterpriseManaged?: boolean;
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
clientInformation?: OAuthClientInformation;
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
clientRegistrationKind?: OAuthClientRegistrationKind;
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
tokens?: OAuthTokens;
}
/**
* OAuth store state (all servers) plus mutation helpers.
*/
export interface OAuthStoreState {
servers: Record<string, ServerOAuthState>;
idpSessions: Record<string, IdpSessionState>;
getServerState: (serverUrl: string) => ServerOAuthState;
setServerState: (serverUrl: string, state: Partial<ServerOAuthState>) => void;
clearServerState: (serverUrl: string) => void;
getIdpSession: (issuer: string) => IdpSessionState;
setIdpSession: (issuer: string, updates: Partial<IdpSessionState>) => void;
clearIdpSession: (issuer: string) => void;
clearEnterpriseManagedResourceServers: () => void;
}
/**
* Mutable in-memory OAuth state keyed by server URL and IdP issuer.
*/
export class OAuthMemoryStore {
private servers: Record<string, ServerOAuthState> = {};
private idpSessions: Record<string, IdpSessionState> = {};
constructor(initial?: OAuthPersistSnapshot) {
if (initial) {
this.replace(initial);
}
}
getState(): OAuthStoreState {
return {
servers: this.servers,
idpSessions: this.idpSessions,
getServerState: (serverUrl: string) => {
return this.servers[serverUrl] || {};
},
setServerState: (
serverUrl: string,
updates: Partial<ServerOAuthState>,
) => {
this.servers = {
...this.servers,
[serverUrl]: {
...this.servers[serverUrl],
...updates,
},
};
},
clearServerState: (serverUrl: string) => {
const rest = { ...this.servers };
delete rest[serverUrl];
this.servers = rest;
},
getIdpSession: (issuer: string) => {
return this.idpSessions[issuer] || {};
},
setIdpSession: (issuer: string, updates: Partial<IdpSessionState>) => {
this.idpSessions = {
...this.idpSessions,
[issuer]: {
...this.idpSessions[issuer],
...updates,
},
};
},
clearIdpSession: (issuer: string) => {
const rest = { ...this.idpSessions };
delete rest[issuer];
this.idpSessions = rest;
},
clearEnterpriseManagedResourceServers: () => {
const rest = { ...this.servers };
for (const [url, serverState] of Object.entries(this.servers)) {
if (serverState.enterpriseManaged === true) {
delete rest[url];
}
}
this.servers = rest;
},
};
}
snapshot(): OAuthPersistSnapshot {
return {
servers: { ...this.servers },
idpSessions: { ...this.idpSessions },
};
}
replace(snapshot: OAuthPersistSnapshot): void {
this.servers = { ...(snapshot.servers ?? {}) };
this.idpSessions = { ...(snapshot.idpSessions ?? {}) };
}
}