Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
scopeOnRefresh: false,
revokeOnClear: true,
});
});
Expand Down Expand Up @@ -968,6 +969,49 @@ describe("ServerSettingsForm", () => {
);
});

// #2805 — the refresh-scope opt-in. Off by default, so the SDK's refresh
// request stays exactly as it was unless the user asks otherwise.
it("renders Send scope on token refresh unchecked by default", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByLabelText("Send scope on token refresh"),
).not.toBeChecked();
});

it("renders Send scope on token refresh checked when the server opted in", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, oauthScopeOnRefresh: true }}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Send scope on token refresh")).toBeChecked();
});

it("toggles Send scope on token refresh through onOAuthChange", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onOAuthChange={onOAuthChange}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
await user.click(screen.getByLabelText("Send scope on token refresh"));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({ scopeOnRefresh: true }),
);
});

// #2144 — the RFC 7009 opt-out. On by default: leaving it off silently is
// what leaves the authorization server holding a live grant.
it("renders Revoke tokens on clear checked by default", () => {
Expand Down Expand Up @@ -1513,6 +1557,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: true,
requestRefreshToken: true,
scopeOnRefresh: false,
revokeOnClear: true,
});
});
Expand Down Expand Up @@ -1675,6 +1720,7 @@ describe("ServerSettingsForm", () => {
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
scopeOnRefresh: false,
revokeOnClear: true,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,8 @@ export function ServerSettingsForm({
onInsufficientScope: settings.oauthOnInsufficientScope,
// Unset means the default, on — only an explicit opt-out is stored.
requestRefreshToken: settings.oauthRequestRefreshToken ?? true,
// Unset means the default, off — only an explicit opt-in is stored.
scopeOnRefresh: settings.oauthScopeOnRefresh ?? false,
// Unset means the default, on — only an explicit opt-out is stored.
revokeOnClear: settings.oauthRevokeOnClear ?? true,
};
Expand Down Expand Up @@ -672,6 +674,15 @@ export function ServerSettingsForm({
});
}

function handleScopeOnRefreshChange(
event: ChangeEvent<HTMLInputElement>,
): void {
onOAuthChange({
...currentOAuth(),
scopeOnRefresh: event.currentTarget.checked,
});
}

function handleRevokeOnClearChange(
event: ChangeEvent<HTMLInputElement>,
): void {
Expand Down Expand Up @@ -1067,6 +1078,12 @@ export function ServerSettingsForm({
checked={settings.oauthRequestRefreshToken ?? true}
onChange={handleRequestRefreshTokenChange}
/>
<Checkbox
label="Send scope on token refresh"
description="Includes the granted scope on the refresh-token request. Check it for authorization servers that reject a refresh without one, such as Microsoft Entra ID answering AADSTS90009 when the client and the resource are the same app registration. Leave it unchecked otherwise: some authorization servers narrow or alter the grant when a refresh carries a scope. Applies on the next connect."
checked={settings.oauthScopeOnRefresh ?? false}
onChange={handleScopeOnRefreshChange}
/>
<Checkbox
label="Revoke tokens on clear"
description="Calls the authorization server's RFC 7009 revocation endpoint when the stored OAuth state is cleared, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,43 @@ describe("ServerSettingsModal", () => {
);
});

// #2805 — the inverse of the refresh-token mapping: off is the default, so
// only the opt-in persists and unchecking it writes nothing.
it("maps the refresh-scope opt-in into settings, and back to unset", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const { rerender } = renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: /OAuth Settings/i }));
await user.click(screen.getByLabelText("Send scope on token refresh"));
expect(onSettingsChange).toHaveBeenLastCalledWith(
expect.objectContaining({ oauthScopeOnRefresh: true }),
);

rerender(
<ServerSettingsModal
opened
settings={{ ...initialSettings, oauthScopeOnRefresh: true }}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByLabelText("Send scope on token refresh"));
expect(onSettingsChange).toHaveBeenLastCalledWith(
expect.objectContaining({ oauthScopeOnRefresh: undefined }),
);
});

// #2317 — omit-when-off, so an untouched server writes no field.
it("maps the notification-stream suppression into settings, and back to unset", async () => {
const user = userEvent.setup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export function ServerSettingsModal({
// server that never touched the switch writes no field at all.
oauthRequestRefreshToken:
oauth.requestRefreshToken === false ? false : undefined,
// #2805: the inverse — `undefined` means off, so only the opt-in persists.
oauthScopeOnRefresh: oauth.scopeOnRefresh === true ? true : undefined,
// #2144: same shape — `undefined` means on, so only the opt-out persists.
oauthRevokeOnClear: oauth.revokeOnClear === false ? false : undefined,
});
Expand Down
19 changes: 17 additions & 2 deletions clients/web/src/hooks/useConnectionLifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,8 +559,8 @@ describe("useConnectionLifecycle", () => {
});

it("forwards the refresh-token opt-out on its own", () => {
// The last operand of the "is there any per-server OAuth?" chain, so it
// is the only shape that reaches it — every other field short-circuits.
// A late operand of the "is there any per-server OAuth?" chain, so it
// is the only shape that decides it — every earlier field short-circuits.
const settings: InspectorServerSettings = {
...EMPTY_SETTINGS,
oauthRequestRefreshToken: false,
Expand All @@ -574,6 +574,21 @@ describe("useConnectionLifecycle", () => {
expect(client.getServerSettings()?.oauthRequestRefreshToken).toBe(false);
});

it("forwards the refresh-scope opt-in on its own (#2805)", () => {
// Now the last operand of the "is there any per-server OAuth?" chain.
const settings: InspectorServerSettings = {
...EMPTY_SETTINGS,
oauthScopeOnRefresh: true,
};
const h = harness({
servers: [entry("a", { settings })],
persisted: { a: settings },
});

const client = h.published()!(entry("a", { settings }));
expect(client.getServerSettings()?.oauthScopeOnRefresh).toBe(true);
});

it("threads the per-server authorization params and endpoint overrides", () => {
const settings: InspectorServerSettings = {
...EMPTY_SETTINGS,
Expand Down
8 changes: 7 additions & 1 deletion clients/web/src/hooks/useConnectionLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,8 @@ export function useConnectionLifecycle({
serverAuthorizationParams ||
serverEndpointOverrides ||
savedSettings.enterpriseManaged ||
savedSettings.oauthRequestRefreshToken === false)
savedSettings.oauthRequestRefreshToken === false ||
savedSettings.oauthScopeOnRefresh === true)
? {
...(savedSettings.oauthClientId && {
clientId: savedSettings.oauthClientId,
Expand All @@ -469,6 +470,11 @@ export function useConnectionLifecycle({
...(savedSettings.oauthRequestRefreshToken === false && {
requestRefreshToken: false,
}),
// #2805: only the opt-in is forwarded; omitting the key leaves the
// SDK's default refresh request (no `scope`) in place.
...(savedSettings.oauthScopeOnRefresh === true && {
scopeOnRefresh: true,
}),
}
: undefined;
const oauth =
Expand Down
10 changes: 10 additions & 0 deletions clients/web/src/test/core/auth/ema/transportProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ interface FakeInner {
codeVerifier: ReturnType<typeof vi.fn>;
saveDiscoveryState: ReturnType<typeof vi.fn>;
discoveryState: ReturnType<typeof vi.fn>;
scopeForRefresh: ReturnType<typeof vi.fn>;
}

function createInner(): FakeInner {
Expand All @@ -86,6 +87,7 @@ function createInner(): FakeInner {
codeVerifier: vi.fn(() => "verifier-xyz"),
saveDiscoveryState: vi.fn(),
discoveryState: vi.fn(),
scopeForRefresh: vi.fn(async () => "mcp tools:read"),
};
}

Expand Down Expand Up @@ -170,6 +172,14 @@ describe("EmaTransportOAuthProvider", () => {
expect(inner.discoveryState).toHaveBeenCalled();
});

// #2805: the per-server setting lives on the inner provider, so the wrapper
// must forward it or an EMA connection silently refreshes without `scope`.
it("delegates scopeForRefresh to the inner provider", async () => {
const tokens = { access_token: "at", token_type: "Bearer" };
expect(await provider.scopeForRefresh(tokens)).toBe("mcp tools:read");
expect(inner.scopeForRefresh).toHaveBeenCalledWith(tokens);
});

it("tokens() returns stored tokens when the access token is still usable", async () => {
const stored: OAuthTokens = {
access_token: VALID_ACCESS_TOKEN,
Expand Down
140 changes: 140 additions & 0 deletions clients/web/src/test/core/auth/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,146 @@ describe("OAuthNavigation", () => {
});
});

describe("scopeForRefresh (#2805)", () => {
function makeRefreshProvider(opts: {
scopeOnRefresh?: boolean;
storedScope?: string;
configuredScope?: string;
requestRefreshToken?: boolean;
}): BaseOAuthClientProvider {
const storage = makeStorage();
vi.mocked(storage.getScope).mockResolvedValue(opts.storedScope);
return new BaseOAuthClientProvider(SERVER, {
storage,
redirectUrlProvider: new MutableRedirectUrlProvider(),
navigation: new CallbackNavigation(vi.fn()),
scopeOnRefresh: opts.scopeOnRefresh,
configuredScope: opts.configuredScope,
requestRefreshToken: opts.requestRefreshToken,
});
}

const grantedTokens = {
access_token: "at",
token_type: "Bearer",
refresh_token: "rt",
scope: "mcp tools:read",
};

// The default must leave the SDK's refresh request untouched: an
// `undefined` here is what makes `auth()` send no `scope` at all.
it("sends no scope by default", async () => {
const provider = makeRefreshProvider({ storedScope: "mcp" });
expect(await provider.scopeForRefresh(grantedTokens)).toBeUndefined();
});

it("sends no scope when explicitly off", async () => {
const provider = makeRefreshProvider({
scopeOnRefresh: false,
storedScope: "mcp",
});
expect(await provider.scopeForRefresh(grantedTokens)).toBeUndefined();
});

it("sends the granted scope saved with the tokens", async () => {
const provider = makeRefreshProvider({
scopeOnRefresh: true,
storedScope: "mcp",
configuredScope: "mcp",
});
expect(await provider.scopeForRefresh(grantedTokens)).toBe(
"mcp tools:read",
);
});

// RFC 6749 §5.1: a token response omits `scope` when it equals the
// requested one, so the request scope is what was granted.
it("falls back to the stored request scope when the tokens carry none", async () => {
const provider = makeRefreshProvider({
scopeOnRefresh: true,
storedScope: " mcp offline_access ",
configuredScope: "mcp",
});
expect(
await provider.scopeForRefresh({ ...grantedTokens, scope: " " }),
).toBe("mcp offline_access");
});

it("falls back to the configured scope when storage holds none", async () => {
const provider = makeRefreshProvider({
scopeOnRefresh: true,
configuredScope: "mcp",
});
expect(
await provider.scopeForRefresh({
access_token: "at",
token_type: "Bearer",
}),
).toBe("mcp");
});

it("returns undefined when no scope is known anywhere", async () => {
const provider = makeRefreshProvider({ scopeOnRefresh: true });
expect(
await provider.scopeForRefresh({
access_token: "at",
token_type: "Bearer",
}),
).toBeUndefined();
});

// The fallback is the scope the authorization request used, and that
// request went through the #2068 filter — so the fallback does too.
it("applies the #2068 offline_access filter to the fallback scope", async () => {
const provider = makeRefreshProvider({
scopeOnRefresh: true,
storedScope: "mcp offline_access",
configuredScope: "mcp",
requestRefreshToken: false,
});
expect(
await provider.scopeForRefresh({
access_token: "at",
token_type: "Bearer",
}),
).toBe("mcp");
});

// Copilot on #2809: the #2068 helper re-adds a configured
// `offline_access`. After the configuration gained it, that token is not
// in the recorded grant, and asking a refresh for it fails with
// `invalid_scope` — so the fallback must stay within the record.
it("never adds a scope the recorded grant lacks", async () => {
const provider = makeRefreshProvider({
scopeOnRefresh: true,
storedScope: "mcp",
configuredScope: "mcp offline_access",
requestRefreshToken: false,
});
expect(
await provider.scopeForRefresh({
access_token: "at",
token_type: "Bearer",
}),
).toBe("mcp");
});

it("returns undefined when the filter removes the whole recorded scope", async () => {
const provider = makeRefreshProvider({
scopeOnRefresh: true,
storedScope: "offline_access",
configuredScope: "mcp",
requestRefreshToken: false,
});
expect(
await provider.scopeForRefresh({
access_token: "at",
token_type: "Bearer",
}),
).toBeUndefined();
});
});

describe("SEP-2352 issuer threading", () => {
it("forwards ctx.issuer to storage on clientInformation/tokens reads", async () => {
const storage = makeStorage();
Expand Down
Loading
Loading