fix(oauth): stop claiming the opt-out removes every offline_access

Copilot review round 2 on #2114. The setting was described as
guaranteeing "no offline_access, no forced prompt", which is not what
the SDK does.

`startAuthorization` appends `prompt=consent` for any `offline_access`
in the effective scope and never consults `grant_types`, and
`determineScope` skips its augmentation when the scope already carries
it. So the prompt still goes out when the user configured
`offline_access` in Scopes, or left Scopes blank and the resource
advertises it in `scopes_supported`. The flag removes the SDK's
*automatic* augmentation and nothing more; the docs, the help text and
the type say that now.

The form also flags the case a user can act on: opted out while
`offline_access` is still listed in Scopes. Whole-token match, so
`offline_access_extra` does not trip it.

Corrects the Clear stored OAuth state claim too. It clears the local
tokens and client information — it is not a revocation and cannot
reach the AS. A dynamically registered client re-registers on the next
connect (the new registration declaring only `authorization_code`); a
preconfigured `oauth.clientId` is reused unchanged.

Adds the two missing seam tests, both confirmed to fail without the
code they cover: App's connect wiring (deleting the spread fails two of
the three) and an /api/servers POST followed by a read-back, since a
200 alone never proved the opt-out persisted.

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 20:35:14 -04:00
commit fca3f1ce67
2 changed files with 230 additions and 4 deletions

View file

@ -751,6 +751,73 @@ describe("ServerSettingsForm", () => {
);
});
it("warns when opting out while offline_access is still in Scopes", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthRequestRefreshToken: false,
oauthScopes: "openid offline_access",
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.getByText("offline_access is still requested"),
).toBeInTheDocument();
});
it("does not warn when opted out and Scopes omits offline_access", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthRequestRefreshToken: false,
oauthScopes: "openid",
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText("offline_access is still requested"),
).not.toBeInTheDocument();
});
// The scope alone is not a problem — with the grant declared, requesting
// offline_access is exactly what the default configuration does.
it("does not warn when offline_access is in Scopes but the grant is on", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, oauthScopes: "openid offline_access" }}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText("offline_access is still requested"),
).not.toBeInTheDocument();
});
// A substring must not trip it — `offline_access_extra` is a different scope.
it("matches offline_access as a whole scope token, not a substring", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
oauthRequestRefreshToken: false,
oauthScopes: "openid offline_access_extra",
}}
expandedSections={["oauth"]}
/>,
);
expect(
screen.queryByText("offline_access is still requested"),
).not.toBeInTheDocument();
});
// #2018 — custom authorization-request parameters.
it("shows the empty hint for authorization parameters when none are set", () => {
renderWithMantine(

View file

@ -530,6 +530,15 @@ export function ServerSettingsForm({
? " Not applied while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server."
: "";
// #2068 — the opt-out only removes the SDK's *automatic* `offline_access`.
// `prompt=consent` keys off the scope, not the grant, so an `offline_access`
// the user typed here still forces the prompt the checkbox claims to avoid.
// Flag that combination rather than letting it read as a fixed configuration.
const scopesIncludeOfflineAccess = (settings.oauthScopes ?? "")
.split(/\s+/)
.includes("offline_access");
const refreshTokenOptedOut = settings.oauthRequestRefreshToken === false;
const rejectedParamKeys = authorizationParams
.map((p) => p.key.trim())
.filter((key) => isReservedAuthorizationParam(key));
@ -883,7 +892,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. 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. Applies on the next connect. Unchecking it does not revoke a refresh token already issued, nor the grant already registered at the authorization server — use Clear stored OAuth state below for that."
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."
checked={settings.oauthRequestRefreshToken ?? true}
onChange={(e) =>
onOAuthChange({
@ -892,6 +901,14 @@ export function ServerSettingsForm({
})
}
/>
{refreshTokenOptedOut && scopesIncludeOfflineAccess ? (
<Alert color="yellow" title="offline_access is still requested">
Scopes above lists offline_access, and prompt=consent is added
whenever that scope is requested — regardless of this setting.
Remove it from Scopes as well, or the consent prompt this
checkbox is meant to avoid still goes out.
</Alert>
) : null}
<Stack gap="xs">
<FieldLabel>Additional authorization parameters</FieldLabel>
<FieldDescription>