OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1694)
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>
This commit is contained in:
Cliff Hall 2026-07-16 16:52:53 -04:00 • committed by GitHub
commit 7fecebed5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1620 additions and 601 deletions

View file

@ -1071,8 +1071,12 @@ describe("InspectorClient OAuth E2E", () => {
expect(client.getStatus()).toBe("connected");
type StoredTokens = { tokens?: { access_token?: string } };
type StateShape = {
servers?: Record<string, { tokens?: { access_token?: string } }>;
servers?: Record<
string,
StoredTokens & { byIssuer?: Record<string, StoredTokens> }
>;
};
// Persistence is fire-and-forget; await the write rather than polling.
await flushStoreFileWrites(customPath);
@ -1081,8 +1085,16 @@ describe("InspectorClient OAuth E2E", () => {
) as StateShape;
const servers = parsed.servers ?? {};
expect(Object.keys(servers).length).toBeGreaterThan(0);
// SEP-2352: tokens persist under `byIssuer[issuer].tokens`; accept the
// legacy top-level slot too.
expect(
Object.values(servers).some((s) => !!s?.tokens?.access_token),
Object.values(servers).some(
(s) =>
!!s?.tokens?.access_token ||
Object.values(s?.byIssuer ?? {}).some(
(slot) => !!slot?.tokens?.access_token,
),
),
).toBe(true);
} finally {
try {

View file

@ -351,12 +351,24 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
"oauth",
remoteAuthToken!,
(body) => {
// SEP-2352: tokens persist under `byIssuer[issuer].tokens`, keyed by
// authorization-server issuer. Accept the legacy top-level slot too.
type StoredTokens = { tokens?: { access_token?: string } };
const b = body as {
servers?: Record<string, { tokens?: { access_token?: string } }>;
servers?: Record<
string,
StoredTokens & { byIssuer?: Record<string, StoredTokens> }
>;
};
return !!(
b?.servers &&
Object.values(b.servers).some((s) => s?.tokens?.access_token)
Object.values(b.servers).some(
(s) =>
s?.tokens?.access_token ||
Object.values(s?.byIssuer ?? {}).some(
(slot) => slot?.tokens?.access_token,
),
)
);
},
);

View file

@ -259,6 +259,38 @@ describe("Transport", () => {
}
});
it("forwards settings.oauthOnInsufficientScope to the streamable-http transport (SEP-2350)", () => {
const config: MCPServerConfig = {
type: "streamable-http",
url: "https://mcp.example.com/mcp",
};
const baseSettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
maxFetchRequests: 1000,
roots: [],
};
const asThrow = createTransportNode(config, {
settings: { ...baseSettings, oauthOnInsufficientScope: "throw" },
});
expect(
(asThrow.transport as unknown as { _onInsufficientScope?: string })
._onInsufficientScope,
).toBe("throw");
// Unset → the SDK's default policy.
const asDefault = createTransportNode(config, { settings: baseSettings });
expect(
(asDefault.transport as unknown as { _onInsufficientScope?: string })
._onInsufficientScope,
).toBe("reauthorize");
});
it("applies settings.headers to the outgoing SSE request", async () => {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),

View file

@ -1478,6 +1478,67 @@ describe("/api/servers routes", () => {
expect(body.error).toMatch(/settings\.env/);
});
it("rejects an invalid settings.oauthOnInsufficientScope with 400", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
srv: { type: "streamable-http", url: "https://x.test/mcp" },
},
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
env: [],
oauthOnInsufficientScope: "nope",
},
}),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error?: string };
expect(body.error).toMatch(/oauthOnInsufficientScope/);
});
it("persists a valid settings.oauthOnInsufficientScope onto the stored config", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
srv: { type: "streamable-http", url: "https://x.test/mcp" },
},
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
env: [],
oauthOnInsufficientScope: "throw",
},
}),
});
expect(res.status).toBe(200);
const stored = JSON.parse(readFileSync(h.configPath, "utf-8")) as {
mcpServers: Record<
string,
{ oauth?: { onInsufficientScope?: string } }
>;
};
expect(stored.mcpServers.srv?.oauth?.onInsufficientScope).toBe("throw");
});
it("ignores settings env/cwd for a non-stdio server (no leak onto config)", async () => {
writeFileSync(
h.configPath,