fix(oauth): drop an inherited offline_access when the grant is declined

Copilot review round 4 on #2114 — a real gap, not a wording one.

Declining the grant stops the SDK *adding* `offline_access`, but the
Inspector's own storage puts it back. A successful default-on
authorization persists the scope the AS granted
(`resolvePersistedScopeAfterGrant`), `offline_access` included;
`prepareForAuth` reloads it and `authenticate` hands it to the SDK as
`scope`, which sees the token in scope and appends `prompt=consent`
again. So any server that had authorized once could uncheck the box and
still hit AADSTS90094 — the users most likely to reach for the setting
were the ones it did not help.

`scopeForDeclinedRefreshGrant` filters it out of the *request* only,
applied in the provider's `scope` getter — the one seam both
`clientMetadata.scope` and `authenticate`'s `scope:` argument read
through. An `offline_access` the user configured is honored; storage is
never rewritten, so re-checking the box restores the old behavior
instead of finding the value destroyed, and scope-satisfaction checks
still see the real grant. Verified by reverting the filter: the new e2e
regression fails.

Also from that round, both previously suppressed:

The checkbox looked effective under enterprise-managed authorization,
which never builds this provider at all — it authorizes against the
enterprise IdP with a fixed `openid offline_access`
(`ema/constants.ts:16`). It now annotates itself the way the endpoint
overrides do. The existing count assertion covered two such notices and
was named for the overrides alone; it is renamed and expects three, with
a dedicated case pinning the notice to this control so the count cannot
be met by three copies elsewhere.

And "obtains no new refresh token" was another overstatement — the AS
decides issuance, and a preconfigured client is reused with whatever its
registration already permits. Replaced with what the setting actually
controls.

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 21:30:29 -04:00
commit 89dedd8dea
2 changed files with 323 additions and 4 deletions

View file

@ -818,6 +818,33 @@ describe("ServerSettingsForm", () => {
).not.toBeInTheDocument();
});
// #2068 — EMA never builds the Inspector's OAuth provider at all; it
// authorizes against the enterprise IdP with a fixed `openid offline_access`.
// The checkbox therefore cannot affect that request and must say so.
it("says the refresh-token setting is not applied under EMA", () => {
const { rerender } = renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, enterpriseManaged: true }}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText(/which this setting does not reach/),
).toBeInTheDocument();
rerender(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, enterpriseManaged: false }}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText(/which this setting does not reach/),
).not.toBeInTheDocument();
});
// #2018 — custom authorization-request parameters.
it("shows the empty hint for authorization parameters when none are set", () => {
renderWithMantine(
@ -1110,7 +1137,7 @@ describe("ServerSettingsForm", () => {
);
});
it("says the endpoint overrides are unused under enterprise-managed auth", () => {
it("says the EMA-suppressed controls are unused under enterprise-managed auth", () => {
const { rerender } = renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
@ -1118,11 +1145,15 @@ describe("ServerSettingsForm", () => {
expandedSections={["oauth"]}
/>,
);
// Three controls carry this notice: the two endpoint overrides (#1906) and
// the refresh-token checkbox (#2068). The count is asserted rather than
// merely "present" so a control that stops annotating itself — or a new one
// that never starts — is caught here.
expect(
screen.getAllByText(
/Not applied while Enterprise-managed authorization is on/,
),
).toHaveLength(2);
).toHaveLength(3);
rerender(
<ServerSettingsForm

View file

@ -539,6 +539,15 @@ export function ServerSettingsForm({
.includes("offline_access");
const refreshTokenOptedOut = settings.oauthRequestRefreshToken === false;
// #2068 — suppressed under EMA for a different reason than the endpoint
// overrides: that leg never builds a BaseOAuthClientProvider at all. It
// authorizes against the enterprise IdP with its own fixed scope, so this
// checkbox cannot influence the request. Say so rather than leaving a control
// that looks effective, matching how the overrides annotate themselves.
const refreshTokenEmaNote = settings.enterpriseManaged
? " Not applied while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP with its own scope, which this setting does not reach."
: "";
const rejectedParamKeys = authorizationParams
.map((p) => p.key.trim())
.filter((key) => isReservedAuthorizationParam(key));
@ -892,7 +901,7 @@ export function ServerSettingsForm({
/>
<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. Unchecking it stops the SDK adding offline_access to the requested scope, and so stops the prompt=consent it forces — on Microsoft Entra ID that prompt routes a non-admin user into the admin-consent workflow (AADSTS90094) even after a tenant admin has consented. It removes only that automatic scope: an offline_access you configure in Scopes still forces the prompt. Applies on the next connect, and does not revoke a refresh token already issued or reach a registration the authorization server already holds."
description={`When checked, the Inspector registers with the refresh_token grant so it can renew access tokens without a new sign-in. Unchecking it stops the SDK adding offline_access to the requested scope, and so stops the prompt=consent it forces — on Microsoft Entra ID that prompt routes a non-admin user into the admin-consent workflow (AADSTS90094) even after a tenant admin has consented. It removes only that automatic scope: an offline_access you configure in Scopes still forces the prompt. Applies on the next connect, and does not revoke a refresh token already issued or reach a registration the authorization server already holds.${refreshTokenEmaNote}`}
checked={settings.oauthRequestRefreshToken ?? true}
onChange={(e) =>
onOAuthChange({