inspector/core/auth/ema/emaFlow.ts
cliffhall 9a2616652c 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>
2026-08-25 01:15:55 -04:00

202 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { OAuthTokens } from "@modelcontextprotocol/client";
import type { OAuthStorage } from "../storage.js";
import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js";
import {
completeIdpOidcAuthorization,
getValidIdToken,
startIdpOidcAuthorization,
} from "./idpOidc.js";
import {
discoverEmaResourceContext,
type EmaResourceContext,
} from "./resourceContext.js";
import { exchangeIdJag, redeemIdJagForAccessToken } from "./wire.js";
export interface EmaFlowConfig {
serverUrl: string;
idp: EnterpriseManagedAuthIdpConfig;
resourceClientId?: string;
resourceClientSecret?: string;
scope?: string;
redirectUrl: string;
storage: OAuthStorage;
fetchFn?: typeof fetch;
}
export type TrySilentEmaAuthResult =
| { status: "success" }
| { status: "no_idp_session" }
| { status: "mint_failed"; error: Error };
function wrapEmaMintError(err: unknown): Error {
const message = err instanceof Error ? err.message : String(err);
return new Error(`EMA legs 2–3 (resource token mint): ${message}`, {
cause: err,
});
}
export async function mintEmaResourceTokens(
config: EmaFlowConfig,
resourceContext?: EmaResourceContext,
): Promise<OAuthTokens> {
const ctx =
resourceContext ??
(await discoverEmaResourceContext(
config.serverUrl,
config.scope,
config.fetchFn,
));
if (!config.resourceClientId) {
throw new Error(
"EMA requires resource authorization server clientId (per-server oauth.clientId)",
);
}
if (!config.resourceClientSecret?.trim()) {
throw new Error(
"EMA requires resource authorization server client secret in server OAuth settings (Test Client secret from xaa.dev resource registration)",
);
}
const idToken = await getValidIdToken({
idp: config.idp,
storage: config.storage,
fetchFn: config.fetchFn,
});
if (!idToken) {
throw new Error("Valid IdP ID Token required for EMA token mint");
}
const audience = ctx.resourceAsUrl.href.replace(/\/$/, "");
// SDK leg-2 helper requires `resource` on the wire; fall back to PRM `resource`.
const resource = ctx.resourceUrl?.href ?? ctx.resourceMetadata.resource;
const idJag = await exchangeIdJag({
idp: config.idp,
idToken,
audience,
resource,
scope: ctx.scope,
fetchFn: config.fetchFn,
});
return redeemIdJagForAccessToken({
resourceAsUrl: ctx.resourceAsUrl,
idJag,
resourceClientId: config.resourceClientId,
resourceClientSecret: config.resourceClientSecret,
resource: ctx.resourceUrl?.href,
scope: ctx.scope,
fetchFn: config.fetchFn,
});
}
/** Silent path: cached IdP session + legs 2–3. */
export async function trySilentEmaAuth(
config: EmaFlowConfig,
): Promise<TrySilentEmaAuthResult> {
const idToken = await getValidIdToken({
idp: config.idp,
storage: config.storage,
fetchFn: config.fetchFn,
});
if (!idToken) {
return { status: "no_idp_session" };
}
try {
const tokens = await mintEmaResourceTokens(config);
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return { status: "success" };
} catch (err) {
return { status: "mint_failed", error: wrapEmaMintError(err) };
}
}
export async function startEmaIdpAuthorization(
config: EmaFlowConfig,
): Promise<URL> {
const { authorizationUrl } = await startIdpOidcAuthorization({
idp: config.idp,
redirectUrl: config.redirectUrl,
storage: config.storage,
fetchFn: config.fetchFn,
});
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> {
try {
await completeIdpOidcAuthorization({
idp: config.idp,
authorizationCode,
iss,
redirectUrl: config.redirectUrl,
storage: config.storage,
fetchFn: config.fetchFn,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`EMA leg 1 (IdP authorization code exchange): ${message}`, {
cause: err,
});
}
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 {
resourceContext = await discoverEmaResourceContext(
config.serverUrl,
config.scope,
config.fetchFn,
);
tokens = await mintEmaResourceTokens(config, resourceContext);
} catch (err) {
throw wrapEmaMintError(err);
}
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return { tokens, requestedScope: resourceContext.scope };
}
/** Re-run legs 2–3 on 401 when IdP session is still valid. */
export async function refreshEmaResourceTokens(
config: EmaFlowConfig,
): Promise<OAuthTokens | undefined> {
const idToken = await getValidIdToken({
idp: config.idp,
storage: config.storage,
fetchFn: config.fetchFn,
});
if (!idToken) {
return undefined;
}
const tokens = await mintEmaResourceTokens(config);
await config.storage.saveTokens(config.serverUrl, tokens, {
enterpriseManaged: true,
});
return tokens;
}