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
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue