fix(oauth): record every redirect's scope, and what EMA really minted

Two more paths had the same defect, both found in review.

`handleStandardAuthChallenge` returns an interactive redirect for
`unauthorized` / `token_expired` / `invalid_token` as well, and its
callback lands in the same completeOAuthFlow -- but the recording was
gated on `insufficient_scope`. Those challenges compute their own
`scopeForAuth`, which can differ from what storage holds, so a silent
token response left the stale scope standing. The read is now one
private helper both entry points call, applied to every redirect.

And `scopeForMint` is not always what the EMA mint asks for: with
neither config nor storage supplying a scope, discoverEmaResourceContext
resolves it from the protected resource metadata's `scopes_supported`,
which is what both exchanges send. `completeEmaIdpAuthorizationAndMint`
now resolves that context itself -- passing it into the mint, so no
extra discovery -- and returns it as `requestedScope` for the manager to
use as the RFC 6749 5.1 fallback.

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:15:55 -04:00
commit 9a2616652c

View file

@ -124,11 +124,26 @@ 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<OAuthTokens> {
): Promise<EmaAuthorizationResult> {
try {
await completeIdpOidcAuthorization({
idp: config.idp,
@ -146,8 +161,17 @@ 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;
try {
tokens = await mintEmaResourceTokens(config);
resourceContext = await discoverEmaResourceContext(
config.serverUrl,
config.scope,
config.fetchFn,
);
tokens = await mintEmaResourceTokens(config, resourceContext);
} catch (err) {
throw wrapEmaMintError(err);
}
@ -155,7 +179,7 @@ export async function completeEmaIdpAuthorizationAndMint(
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return tokens;
return { tokens, requestedScope: resourceContext.scope };
}
/** Re-run legs 2–3 on 401 when IdP session is still valid. */