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
1 changed files with 405 additions and 5 deletions
|
|
@ -1391,6 +1391,7 @@ export function createRemoteApp(
|
|||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
enterpriseManaged?: boolean;
|
||||
requestRefreshToken?: boolean;
|
||||
} => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
|
|
@ -1418,6 +1419,14 @@ export function createRemoteApp(
|
|||
) {
|
||||
return false;
|
||||
}
|
||||
// #2068 — shape only; the read side keeps just an explicit `false`, so a
|
||||
// stray `true` from a hand-edited file reads back as the default anyway.
|
||||
if (
|
||||
o.requestRefreshToken !== undefined &&
|
||||
typeof o.requestRefreshToken !== "boolean"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// A roots array: each entry must have a string `uri` and, when present, a
|
||||
|
|
@ -1531,7 +1540,7 @@ export function createRemoteApp(
|
|||
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "oauth" },
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.",
|
||||
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope?, requestRefreshToken? }`.",
|
||||
);
|
||||
delete valObj.oauth;
|
||||
}
|
||||
|
|
@ -1832,6 +1841,15 @@ export function createRemoteApp(
|
|||
error: "settings.enterpriseManaged must be a boolean",
|
||||
};
|
||||
}
|
||||
if (
|
||||
obj.oauthRequestRefreshToken !== undefined &&
|
||||
typeof obj.oauthRequestRefreshToken !== "boolean"
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.oauthRequestRefreshToken must be a boolean",
|
||||
};
|
||||
}
|
||||
if (
|
||||
obj.oauthOnInsufficientScope !== undefined &&
|
||||
obj.oauthOnInsufficientScope !== "reauthorize" &&
|
||||
|
|
@ -1959,6 +1977,11 @@ export function createRemoteApp(
|
|||
if (obj.enterpriseManaged === true) {
|
||||
value.enterpriseManaged = true;
|
||||
}
|
||||
// #2068: the default is on, so only the explicit opt-out is carried; a
|
||||
// `true` on the wire reads back as unset, which means the same thing.
|
||||
if (obj.oauthRequestRefreshToken === false) {
|
||||
value.oauthRequestRefreshToken = false;
|
||||
}
|
||||
if (
|
||||
obj.oauthOnInsufficientScope === "reauthorize" ||
|
||||
obj.oauthOnInsufficientScope === "throw"
|
||||
|
|
|
|||
Loading…
Reference in a new issue