feat(oauth): make the refresh_token grant opt-out per server
The Inspector always declared `refresh_token` in its OAuth client metadata. The SDK's `determineScope()` then appends `offline_access` whenever the authorization server advertises it and the client declares that grant, and `startAuthorization()` appends `prompt=consent` whenever `offline_access` is in scope. Against Microsoft Entra ID that forced consent prompt routes a non-admin user into the admin-consent workflow and fails with AADSTS90094 — even after a tenant admin has consented. Adds a per-server "Request refresh token" setting (default on) that drops the grant from the client metadata, breaking that chain at its source. Persisted as `oauth.requestRefreshToken` and written only when off, so entries that never touch it keep a byte-stable round-trip. Web edits it in Server Settings → Authorization; the CLI and TUI pick it up from the same per-server settings via `buildRunnerClientAuthOptions`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
9d0448b644
commit
e2fb78d999
2 changed files with 405 additions and 5 deletions
|
|
@ -119,6 +119,43 @@ describe("ServerSettingsModal", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// #2068 — only the opt-out is persisted; re-checking the box clears the field
|
||||
// rather than writing `true`, so a default-on server keeps a minimal entry.
|
||||
it("maps the refresh-token opt-out 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("Request refresh token"));
|
||||
expect(onSettingsChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ oauthRequestRefreshToken: false }),
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={{ ...initialSettings, oauthRequestRefreshToken: false }}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText("Request refresh token"));
|
||||
expect(onSettingsChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ oauthRequestRefreshToken: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the selected protocol era into settings (#1626)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -187,6 +187,10 @@ export function ServerSettingsModal({
|
|||
// the SDK's `reauthorize` behavior without writing a spurious field.
|
||||
oauthOnInsufficientScope:
|
||||
oauth.onInsufficientScope === "throw" ? "throw" : undefined,
|
||||
// #2068: persist only the non-default (off). `undefined` means on, so a
|
||||
// server that never touched the switch writes no field at all.
|
||||
oauthRequestRefreshToken:
|
||||
oauth.requestRefreshToken === false ? false : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue