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:
parent
9a2616652c
commit
c6029e1cbb
2 changed files with 104 additions and 136 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) };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue