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:
cliffhall 2026-08-24 19:50:57 -04:00
commit e2fb78d999
2 changed files with 405 additions and 5 deletions

View file

@ -706,9 +706,51 @@ describe("ServerSettingsForm", () => {
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
});
});
// #2068 — the refresh-token opt-out. On by default; unchecking it is what
// stops the SDK adding `offline_access` and then `prompt=consent`.
it("renders Request refresh token checked by default", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Request refresh token")).toBeChecked();
});
it("renders Request refresh token unchecked when the server opted out", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, oauthRequestRefreshToken: false }}
expandedSections={["oauth"]}
/>,
);
expect(screen.getByLabelText("Request refresh token")).not.toBeChecked();
});
it("toggles Request refresh token 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("Request refresh token"));
expect(onOAuthChange).toHaveBeenLastCalledWith(
expect.objectContaining({ requestRefreshToken: false }),
);
});
// #2018 — custom authorization-request parameters.
it("shows the empty hint for authorization parameters when none are set", () => {
renderWithMantine(
@ -1112,6 +1154,7 @@ describe("ServerSettingsForm", () => {
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: true,
requestRefreshToken: true,
});
});
@ -1219,6 +1262,7 @@ describe("ServerSettingsForm", () => {
authorizationUrl: "",
tokenUrl: "",
enterpriseManaged: false,
requestRefreshToken: true,
});
});

View file

@ -518,6 +518,8 @@ export function ServerSettingsForm({
tokenUrl: settings.oauthTokenUrl ?? "",
enterpriseManaged: settings.enterpriseManaged ?? false,
onInsufficientScope: settings.oauthOnInsufficientScope,
// Unset means the default, on — only an explicit opt-out is stored.
requestRefreshToken: settings.oauthRequestRefreshToken ?? true,
};
}
@ -879,6 +881,17 @@ export function ServerSettingsForm({
) : null
}
/>
<Checkbox
label="Request refresh token"
description="When checked, the Inspector registers with the refresh_token grant so it can renew access tokens without a new sign-in. Uncheck it for authorization servers where that grant forces an unwanted consent prompt — the SDK adds offline_access to the requested scope whenever the grant is declared, and then prompt=consent to the authorization request. On Microsoft Entra ID that routes a non-admin user into the admin-consent workflow (AADSTS90094) even after a tenant admin has consented."
checked={settings.oauthRequestRefreshToken ?? true}
onChange={(e) =>
onOAuthChange({
...currentOAuth(),
requestRefreshToken: e.currentTarget.checked,
})
}
/>
<Stack gap="xs">
<FieldLabel>Additional authorization parameters</FieldLabel>
<FieldDescription>