fix(oauth): persist the granted scope on every EMA mint

`trySilentEmaAuth` and `refreshEmaResourceTokens` saved tokens without
ever saving a scope. Both are reached from real paths -- initial EMA
authentication with a cached IdP session, refreshEnterpriseManagedTokens,
and EmaTransportOAuthProvider.tokens -- and each can be the moment a
newly configured or metadata-derived scope first takes effect, so a
silent token response left the previous stored scope standing.

Rather than a third copy of the rule, every EMA mint now ends in one
place: mintEmaResourceTokens reports the scope it requested, and
saveMintedTokens writes tokens and the RFC 6749 5.1 scope together.
The manager's EMA branch drops its own saveScope, which would now be a
redundant second write of the same value; the step-up branch keeps its
own, since it falls back to the challenge's authorizationScopes union
and runs last.

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-25 01:32:40 -04:00
commit c6029e1cbb
2 changed files with 104 additions and 136 deletions

View file

@ -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) };
@ -124,26 +161,11 @@ export async function startEmaIdpAuthorization(
return authorizationUrl;
}
/**
* The outcome of a completed EMA authorization.
*
* `requestedScope` is the scope the mint actually asked for, which is not
* always `config.scope`: when neither the server config nor storage supplies
* one, {@link discoverEmaResourceContext} resolves it from the protected
* resource metadata's `scopes_supported`. Callers need that resolved value to
* apply RFC 6749 §5.1 — a token response that omits `scope` granted exactly
* what was requested — so it is returned rather than left inside the flow.
*/
export interface EmaAuthorizationResult {
tokens: OAuthTokens;
requestedScope?: string;
}
export async function completeEmaIdpAuthorizationAndMint(
config: EmaFlowConfig,
authorizationCode: string,
iss?: string,
): Promise<EmaAuthorizationResult> {
): Promise<EmaMintResult> {
try {
await completeIdpOidcAuthorization({
idp: config.idp,
@ -160,26 +182,15 @@ export async function completeEmaIdpAuthorizationAndMint(
});
}
let tokens: OAuthTokens;
// Resolve the resource context here rather than inside the mint, so the
// scope it settles on is visible to the caller. It is passed straight
// through, so this costs no extra discovery.
let resourceContext: EmaResourceContext;
let minted: EmaMintResult;
try {
resourceContext = await discoverEmaResourceContext(
config.serverUrl,
config.scope,
config.fetchFn,
);
tokens = await mintEmaResourceTokens(config, resourceContext);
minted = await mintEmaResourceTokens(config);
} catch (err) {
throw wrapEmaMintError(err);
}
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return { tokens, requestedScope: resourceContext.scope };
await saveMintedTokens(config, minted);
return minted;
}
/** Re-run legs 2–3 on 401 when IdP session is still valid. */
@ -194,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;
}

View file

@ -376,26 +376,13 @@ export class OAuthManager {
const config = scopeForMint
? { ...emaConfig, scope: scopeForMint }
: emaConfig;
const { tokens, requestedScope } =
await completeEmaIdpAuthorizationAndMint(
config,
authorizationCode,
iss,
);
// The mint reports what it actually asked for, which is neither
// `pendingAuthorizationScope` (undefined on the ordinary leg) nor
// always `scopeForMint` — with no configured or stored scope the
// resource metadata's `scopes_supported` is what gets requested.
const scopeToPersist = resolvePersistedScopeAfterGrant(
tokens.scope,
requestedScope,
// 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,
);
if (scopeToPersist) {
await this.requireStorage().saveScope(
this.getServerUrl(),
scopeToPersist,
);
}
this.pendingAuthorizationScope = undefined;
const completedAt = Date.now();
this.oauthFlowState = {