Merge branch 'v2/main' into v2/fix/2096-stdio-env-cwd-revert
This commit is contained in:
commit
ca418b4032
2 changed files with 330 additions and 64 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
type EmaResourceContext,
|
||||
} from "./resourceContext.js";
|
||||
import { exchangeIdJag, redeemIdJagForAccessToken } from "./wire.js";
|
||||
import { resolvePersistedScopeAfterGrant } from "../scopes.js";
|
||||
|
||||
export interface EmaFlowConfig {
|
||||
serverUrl: string;
|
||||
|
|
@ -35,10 +36,24 @@ function wrapEmaMintError(err: unknown): Error {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed EMA mint, and the scope it actually asked for.
|
||||
*
|
||||
* The request is not always `config.scope`: with neither a configured nor a
|
||||
* stored scope, {@link discoverEmaResourceContext} resolves it from the
|
||||
* protected resource metadata's `scopes_supported`, and that is what both
|
||||
* exchanges send. Callers need the resolved value to apply RFC 6749 §5.1 — a
|
||||
* token response that omits `scope` granted exactly what was requested.
|
||||
*/
|
||||
export interface EmaMintResult {
|
||||
tokens: OAuthTokens;
|
||||
requestedScope?: string;
|
||||
}
|
||||
|
||||
export async function mintEmaResourceTokens(
|
||||
config: EmaFlowConfig,
|
||||
resourceContext?: EmaResourceContext,
|
||||
): Promise<OAuthTokens> {
|
||||
): Promise<EmaMintResult> {
|
||||
const ctx =
|
||||
resourceContext ??
|
||||
(await discoverEmaResourceContext(
|
||||
|
|
@ -78,7 +93,7 @@ export async function mintEmaResourceTokens(
|
|||
fetchFn: config.fetchFn,
|
||||
});
|
||||
|
||||
return redeemIdJagForAccessToken({
|
||||
const tokens = await redeemIdJagForAccessToken({
|
||||
resourceAsUrl: ctx.resourceAsUrl,
|
||||
idJag,
|
||||
resourceClientId: config.resourceClientId,
|
||||
|
|
@ -87,6 +102,31 @@ export async function mintEmaResourceTokens(
|
|||
scope: ctx.scope,
|
||||
fetchFn: config.fetchFn,
|
||||
});
|
||||
return { tokens, requestedScope: ctx.scope };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a mint's tokens and the scope they carry, together.
|
||||
*
|
||||
* Every EMA mint ends here — silent, refresh, and interactive alike — because
|
||||
* all three can be the moment a newly configured or metadata-derived scope
|
||||
* first takes effect. Persisting tokens without the scope leaves the previous
|
||||
* stored value standing as though it described the new token (#2117).
|
||||
*/
|
||||
async function saveMintedTokens(
|
||||
config: EmaFlowConfig,
|
||||
{ tokens, requestedScope }: EmaMintResult,
|
||||
): Promise<void> {
|
||||
await config.storage.saveTokens(config.serverUrl, tokens, {
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||
tokens.scope,
|
||||
requestedScope,
|
||||
);
|
||||
if (scopeToPersist) {
|
||||
await config.storage.saveScope(config.serverUrl, scopeToPersist);
|
||||
}
|
||||
}
|
||||
|
||||
/** Silent path: cached IdP session + legs 2–3. */
|
||||
|
|
@ -102,10 +142,7 @@ export async function trySilentEmaAuth(
|
|||
return { status: "no_idp_session" };
|
||||
}
|
||||
try {
|
||||
const tokens = await mintEmaResourceTokens(config);
|
||||
await config.storage.saveTokens(config.serverUrl, tokens, {
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
await saveMintedTokens(config, await mintEmaResourceTokens(config));
|
||||
return { status: "success" };
|
||||
} catch (err) {
|
||||
return { status: "mint_failed", error: wrapEmaMintError(err) };
|
||||
|
|
@ -128,7 +165,7 @@ export async function completeEmaIdpAuthorizationAndMint(
|
|||
config: EmaFlowConfig,
|
||||
authorizationCode: string,
|
||||
iss?: string,
|
||||
): Promise<OAuthTokens> {
|
||||
): Promise<EmaMintResult> {
|
||||
try {
|
||||
await completeIdpOidcAuthorization({
|
||||
idp: config.idp,
|
||||
|
|
@ -145,17 +182,15 @@ export async function completeEmaIdpAuthorizationAndMint(
|
|||
});
|
||||
}
|
||||
|
||||
let tokens: OAuthTokens;
|
||||
let minted: EmaMintResult;
|
||||
try {
|
||||
tokens = await mintEmaResourceTokens(config);
|
||||
minted = await mintEmaResourceTokens(config);
|
||||
} catch (err) {
|
||||
throw wrapEmaMintError(err);
|
||||
}
|
||||
|
||||
await config.storage.saveTokens(config.serverUrl, tokens, {
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
return tokens;
|
||||
await saveMintedTokens(config, minted);
|
||||
return minted;
|
||||
}
|
||||
|
||||
/** Re-run legs 2–3 on 401 when IdP session is still valid. */
|
||||
|
|
@ -170,9 +205,7 @@ export async function refreshEmaResourceTokens(
|
|||
if (!idToken) {
|
||||
return undefined;
|
||||
}
|
||||
const tokens = await mintEmaResourceTokens(config);
|
||||
await config.storage.saveTokens(config.serverUrl, tokens, {
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
return tokens;
|
||||
const minted = await mintEmaResourceTokens(config);
|
||||
await saveMintedTokens(config, minted);
|
||||
return minted.tokens;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,26 +331,13 @@ export class OAuthManager {
|
|||
);
|
||||
}
|
||||
|
||||
// #2068 — record the filtered request so `completeOAuthFlow` can persist
|
||||
// it. `resolvePersistedScopeAfterGrant` falls back to the requested scope
|
||||
// when the token response omits `scope` (RFC 6749 §5.1: omitted means the
|
||||
// grant matched the request), and without this that fallback is `undefined`
|
||||
// on the ordinary leg — only step-up ever set it. The old
|
||||
// `offline_access` would then stay in storage, so the scope never
|
||||
// self-heals and later satisfaction checks still read it as granted.
|
||||
//
|
||||
// Only when the filter is in effect. With the grant declared there is
|
||||
// nothing to heal, and persisting a request the AS did not confirm would be
|
||||
// a behavior change beyond this setting.
|
||||
if (this.oauthConfig.requestRefreshToken === false && requestedScope) {
|
||||
this.pendingAuthorizationScope = requestedScope;
|
||||
}
|
||||
|
||||
const capturedUrl = provider.getCapturedAuthUrl();
|
||||
if (!capturedUrl) {
|
||||
throw new Error("Failed to capture authorization URL");
|
||||
}
|
||||
|
||||
this.recordAuthorizationRequestScope(capturedUrl, requestedScope);
|
||||
|
||||
const stateParam = capturedUrl.searchParams.get("state");
|
||||
if (stateParam && this.params.onBeforeOAuthRedirect) {
|
||||
const parsedState = parseOAuthState(stateParam);
|
||||
|
|
@ -369,6 +356,36 @@ export class OAuthManager {
|
|||
return capturedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember what an interactive authorization asked for, so the callback can
|
||||
* apply RFC 6749 §5.1 — a token response that omits `scope` granted exactly
|
||||
* the requested scope (#2117). Without it, an ordinary grant whose AS echoes
|
||||
* no scope leaves the previous stored scope standing as though it were what
|
||||
* the current token carries.
|
||||
*
|
||||
* The authorize URL is the authority, not the scope handed to `auth()`: the
|
||||
* SDK augments the request with `offline_access` when the AS advertises it
|
||||
* and the client asks for a refresh token, so the input can be a strict
|
||||
* subset of what was actually requested. The fallback covers an authorize
|
||||
* URL that carries no `scope` parameter at all.
|
||||
*
|
||||
* This composes with the #2068 refresh-token opt-out rather than defeating
|
||||
* it. The SDK's augmentation is gated on `clientMetadata.grant_types`
|
||||
* including `refresh_token`, which the provider drops when the opt-out is
|
||||
* on — so with the grant declined the URL carries the *filtered* scope, and
|
||||
* that is what gets persisted. #2068 recorded the same value here behind a
|
||||
* `requestRefreshToken === false` gate; reading the URL makes the gate
|
||||
* unnecessary, so one wire case now has one persistence behavior instead of
|
||||
* two selected by a checkbox (#2117).
|
||||
*/
|
||||
private recordAuthorizationRequestScope(
|
||||
authorizationUrl: URL,
|
||||
requestedScope: string | undefined,
|
||||
): void {
|
||||
this.pendingAuthorizationScope =
|
||||
authorizationUrl.searchParams.get("scope")?.trim() || requestedScope;
|
||||
}
|
||||
|
||||
async completeOAuthFlow(
|
||||
authorizationCode: string,
|
||||
iss?: string,
|
||||
|
|
@ -380,22 +397,13 @@ export class OAuthManager {
|
|||
const config = scopeForMint
|
||||
? { ...emaConfig, scope: scopeForMint }
|
||||
: emaConfig;
|
||||
const tokens = await completeEmaIdpAuthorizationAndMint(
|
||||
// The EMA flow persists the granted scope alongside the tokens, for
|
||||
// every mint rather than only this one — see saveMintedTokens.
|
||||
const { tokens } = await completeEmaIdpAuthorizationAndMint(
|
||||
config,
|
||||
authorizationCode,
|
||||
iss,
|
||||
);
|
||||
const requestedScope = this.pendingAuthorizationScope;
|
||||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||
tokens.scope,
|
||||
requestedScope,
|
||||
);
|
||||
if (scopeToPersist) {
|
||||
await this.requireStorage().saveScope(
|
||||
this.getServerUrl(),
|
||||
scopeToPersist,
|
||||
);
|
||||
}
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
const completedAt = Date.now();
|
||||
this.oauthFlowState = {
|
||||
|
|
@ -892,9 +900,12 @@ export class OAuthManager {
|
|||
};
|
||||
}
|
||||
|
||||
if (enriched.reason === "insufficient_scope" && scopeForAuth) {
|
||||
this.pendingAuthorizationScope = scopeForAuth;
|
||||
}
|
||||
// Every interactive redirect from here lands back in completeOAuthFlow,
|
||||
// so every one of them needs its request recorded — not just the step-up
|
||||
// (#2117). A plain `unauthorized` / `token_expired` challenge can request
|
||||
// a scope that differs from what storage holds, and used to persist
|
||||
// nothing when the AS answered without a `scope`.
|
||||
this.recordAuthorizationRequestScope(capturedUrl, scopeForAuth);
|
||||
|
||||
const clientInfo = await provider.clientInformation();
|
||||
await this.recordAuthorizationCodeFlowState(
|
||||
|
|
|
|||
Loading…
Reference in a new issue