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:
parent
6303f8a6ce
commit
89dedd8dea
1 changed files with 323 additions and 4 deletions
|
|
@ -18,6 +18,48 @@ export const computeScopeUnion = sdkComputeScopeUnion;
|
|||
*/
|
||||
export const isStrictScopeSuperset = sdkIsStrictScopeSuperset;
|
||||
|
||||
/** The scope token whose presence makes the SDK force `prompt=consent`. */
|
||||
export const OFFLINE_ACCESS_SCOPE = "offline_access";
|
||||
|
||||
/**
|
||||
* The scope to *request* when the `refresh_token` grant has been declined
|
||||
* (#2068).
|
||||
*
|
||||
* Declining the grant stops the SDK adding `offline_access`, but it cannot
|
||||
* remove one that is already in the scope — and the Inspector's own storage is
|
||||
* the way it gets there. A successful authorization persists the scope the AS
|
||||
* granted (`resolvePersistedScopeAfterGrant`), which for a default-on client
|
||||
* includes `offline_access`; the provider reloads that on the next connect and
|
||||
* hands it back to the SDK, which sees the token in scope and appends
|
||||
* `prompt=consent` again. So a server that authorized once before the opt-out
|
||||
* would keep hitting AADSTS90094 with the box unchecked — the exact failure the
|
||||
* setting exists to prevent.
|
||||
*
|
||||
* Dropping it from the *request* (not from storage) is what breaks that loop:
|
||||
* the next grant comes back without `offline_access` and the persisted scope
|
||||
* self-heals, while re-checking the box restores the old behavior from the
|
||||
* user's own configuration rather than from a value we destroyed.
|
||||
*
|
||||
* A scope the user explicitly configured is left alone — they asked for it, and
|
||||
* the web form warns that it keeps the consent prompt alive.
|
||||
*/
|
||||
export function scopeForDeclinedRefreshGrant(
|
||||
effectiveScope: string | undefined,
|
||||
configuredScope: string | undefined,
|
||||
): string | undefined {
|
||||
if (!effectiveScope) return effectiveScope;
|
||||
const configured = configuredScope?.trim().split(/\s+/) ?? [];
|
||||
if (configured.includes(OFFLINE_ACCESS_SCOPE)) return effectiveScope;
|
||||
|
||||
const kept = effectiveScope
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((token) => token !== "" && token !== OFFLINE_ACCESS_SCOPE);
|
||||
// An all-`offline_access` scope collapses to undefined rather than "", so the
|
||||
// SDK falls back to its own resolution instead of requesting an empty scope.
|
||||
return kept.length > 0 ? kept.join(" ") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to persist after a successful token grant (RFC 6749 §5.1).
|
||||
* When the AS returns `scope`, it is the authoritative full grant.
|
||||
|
|
|
|||
Loading…
Reference in a new issue