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
2 changed files with 192 additions and 35 deletions

View file

@ -378,7 +378,7 @@ describe("emaFlow", () => {
// Seed code verifier / client info / metadata for the callback exchange.
await startEmaIdpAuthorization(config);
const tokens = await completeEmaIdpAuthorizationAndMint(
const { tokens } = await completeEmaIdpAuthorizationAndMint(
config,
"auth-code",
IDP_ISSUER,
@ -392,6 +392,43 @@ describe("emaFlow", () => {
);
});
it("completeEmaIdpAuthorizationAndMint reports a metadata-derived scope (#2117)", async () => {
// With no configured scope, discoverEmaResourceContext resolves the
// request from the protected resource metadata's `scopes_supported`. That
// is what both exchanges send, so it is what an omitted `scope` in the
// response implies was granted -- and the caller can only apply RFC 6749
// 5.1 if the flow hands it back.
const idToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600);
const storage = createMemoryStorage();
const inner = mockEmaFetch(idToken);
const fetchFn = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).includes("/.well-known/oauth-protected-resource")) {
return new Response(
JSON.stringify({
resource: MCP_RESOURCE,
authorization_servers: [AS_ISSUER],
scopes_supported: ["mcp:read", "mcp:write"],
}),
);
}
return inner(input, init);
},
);
const config = { ...baseConfig(storage), scope: undefined, fetchFn };
await startEmaIdpAuthorization(config);
const { tokens, requestedScope } = await completeEmaIdpAuthorizationAndMint(
config,
"auth-code",
IDP_ISSUER,
);
expect(tokens.access_token).toBe("resource-access-token");
expect(requestedScope).toBe("mcp:read mcp:write");
});
it("completeEmaIdpAuthorizationAndMint wraps leg 1 errors", async () => {
const errorSpy = vi
.spyOn(console, "error")

View file

@ -798,6 +798,48 @@ describe("OAuthManager", () => {
);
});
it("records the request for a plain challenge redirect too, not just step-up", async () => {
// A `token_expired` / `unauthorized` challenge can return an interactive
// redirect whose callback lands in this same completeOAuthFlow. It used
// to record nothing, so a silent token response left the old stored
// scope standing — the same defect on a sibling entry point.
mockedMcpAuth.mockResolvedValue("REDIRECT");
const params = createMockParams();
storageOf(params).getScope.mockResolvedValue("stale:scope");
storageOf(params).getClientInformation.mockResolvedValue({
client_id: "cid",
});
const manager = new OAuthManager(params);
manager.setOAuthConfig({ scope: "catalog:scope" });
const captureSpy = vi
.spyOn(
(await import("@inspector/core/auth/providers.js"))
.BaseOAuthClientProvider.prototype,
"getCapturedAuthUrl",
)
.mockReturnValue(capturedUrlWithScope("catalog:scope offline_access"));
const outcome = await manager.handleAuthChallenge({
reason: "token_expired",
});
expect(outcome.kind).toBe("interactive");
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
storageOf(params).getTokens.mockResolvedValue({
access_token: "access",
token_type: "Bearer",
});
storageOf(params).saveScope.mockClear();
await manager.completeOAuthFlow("code");
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
SERVER_URL,
"catalog:scope offline_access",
);
captureSpy.mockRestore();
});
it("persists nothing when neither the request nor the response names a scope", async () => {
const params = await authorizeThenComplete(undefined, undefined);
@ -824,7 +866,7 @@ describe("OAuthManager", () => {
describe("completeOAuthFlow (EMA)", () => {
it("mints resource tokens via the EMA path and dispatches complete", async () => {
const tokens = { access_token: "EMA", token_type: "Bearer" };
const tokens = { access_token: "EMA", token_type: "Bearer" as const };
const params = createMockParams({
enterpriseManagedAuth: {
idp: {
@ -839,7 +881,7 @@ describe("OAuthManager", () => {
const mintSpy = vi
.spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint")
.mockResolvedValue(tokens);
.mockResolvedValue({ tokens });
await manager.completeOAuthFlow("ema-code");
@ -866,7 +908,10 @@ describe("OAuthManager", () => {
const mintSpy = vi
.spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint")
.mockResolvedValue({ access_token: "EMA", token_type: "Bearer" });
.mockResolvedValue({
tokens: { access_token: "EMA", token_type: "Bearer" },
requestedScope: "mcp weather:read",
});
storageOf(params).saveScope.mockClear();
await manager.completeOAuthFlow("ema-code");
@ -877,6 +922,42 @@ describe("OAuthManager", () => {
);
mintSpy.mockRestore();
});
it("persists a mint scope that came from resource metadata, not config (#2117)", async () => {
// With neither a configured nor a stored scope, discoverEmaResourceContext
// resolves the request from the protected resource metadata's
// `scopes_supported`. That value never reaches the manager's own
// `scopeForMint`, so only what the mint reports can be persisted.
const params = createMockParams({
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.example.com",
clientId: "app-client",
clientSecret: "secret",
},
},
});
params.initialConfig.scope = undefined;
storageOf(params).getScope.mockResolvedValue(undefined);
const manager = new OAuthManager(params);
manager.setOAuthConfig({ enterpriseManaged: true });
const mintSpy = vi
.spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint")
.mockResolvedValue({
tokens: { access_token: "EMA", token_type: "Bearer" },
requestedScope: "mcp:read mcp:write",
});
storageOf(params).saveScope.mockClear();
await manager.completeOAuthFlow("ema-code");
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
SERVER_URL,
"mcp:read mcp:write",
);
mintSpy.mockRestore();
});
});
describe("trySilentEnterpriseManagedAuth", () => {
@ -1704,7 +1785,10 @@ describe("OAuthManager", () => {
.mockResolvedValue(authUrl);
const mintSpy = vi
.spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint")
.mockResolvedValue({ access_token: "tok", token_type: "Bearer" });
.mockResolvedValue({
tokens: { access_token: "tok", token_type: "Bearer" },
requestedScope: "mcp tools:read weather:read",
});
const params = createMockParams({
enterpriseManagedAuth: {
idp: {