test(oauth): prove the opt-out against a real authorization server
Copilot review round 3 on #2114. The unit tests asserted our own `grant_types` array and stopped there. What actually unblocks an Entra tenant is what the SDK puts in the authorize URL, and nothing covered that — so an SDK change that stopped keying its augmentation off `grant_types` would leave AADSTS90094 intact with every test green. Adds the complementary case to the existing e2e suite, against a real AS advertising `offline_access`: with the grant declined, the authorize URL carries neither `offline_access` nor `prompt=consent`, while the configured scope still goes out. Verified non-vacuous by reverting the provider conditional — it fails. The positive test alongside it now also asserts `prompt=consent` is present, which was unasserted despite being the literal failure in the issue. Drops an unjustified `as unknown as ServerEntry` I added in the App test — AGENTS.md forbids it, and it hid a real mismatch (a partial settings object against a complete one). Typed `ServerEntry` / `InspectorServerSettings` fixtures instead, so a field added to that interface later is a compile error rather than a missing value at runtime. The `constructedOAuth` helper reads through the constructor's own parameter type and needs no cast at all now. Scopes the two doc comments that still claimed the flag removes every `offline_access` — `StoredMCPServer.oauth` and `OAuthProviderConfig`, both contradicting the caveat already stated elsewhere. 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
fca3f1ce67
commit
6303f8a6ce
2 changed files with 125 additions and 22 deletions
|
|
@ -496,9 +496,11 @@ describe("OAuthNavigation", () => {
|
|||
});
|
||||
|
||||
// The point of the opt-out: the SDK's determineScope() only appends
|
||||
// `offline_access` when the client metadata declares `refresh_token`, and
|
||||
// startAuthorization() only forces `prompt=consent` when `offline_access`
|
||||
// is in scope. Dropping the grant is what breaks that chain.
|
||||
// `offline_access` when the client metadata declares `refresh_token`.
|
||||
// This asserts our half — the grant list. That the authorize URL then
|
||||
// carries neither `offline_access` nor `prompt=consent` is proved against
|
||||
// a real AS in the e2e suite (inspectorClient-oauth-e2e.test.ts), since
|
||||
// the SDK, not this array, is what ultimately writes that URL.
|
||||
it("drops the refresh_token grant when disabled", () => {
|
||||
expect(
|
||||
makeProviderWithRefresh(false).clientMetadata.grant_types,
|
||||
|
|
|
|||
|
|
@ -1383,6 +1383,81 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const scope = authUrl.searchParams.get("scope") ?? "";
|
||||
expect(scope.split(/\s+/)).toContain("offline_access");
|
||||
// The consequence of that scope, and the actual failure in #2068: the SDK
|
||||
// forces an interactive consent prompt. Asserted here so the opt-out case
|
||||
// below is a real before/after pair rather than a scope check alone.
|
||||
expect(authUrl.searchParams.get("prompt")).toBe("consent");
|
||||
});
|
||||
|
||||
// #2068 — the same server, the same AS advertising `offline_access`, with
|
||||
// the grant declined. This is the end-to-end proof the unit tests cannot
|
||||
// give: they assert our `grant_types` array, while the behavior that
|
||||
// actually unblocks an Entra tenant is what the SDK puts in the authorize
|
||||
// URL. If the SDK ever stopped keying its augmentation off `grant_types`,
|
||||
// every other test here would still pass and AADSTS90094 would remain.
|
||||
it("omits offline_access and prompt=consent when the refresh-token grant is declined", async () => {
|
||||
const transport = transports[1]!;
|
||||
const staticClientId = "test-no-offline-access-client";
|
||||
const staticClientSecret = "test-no-offline-access-secret";
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
scopesSupported: ["mcp", "offline_access"],
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
server = new TestServerHttp(serverConfig);
|
||||
const port = await server.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
scope: "mcp",
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
scope: oauthConfig.scope,
|
||||
requestRefreshToken: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const scope = authUrl.searchParams.get("scope") ?? "";
|
||||
expect(scope.split(/\s+/)).not.toContain("offline_access");
|
||||
expect(authUrl.searchParams.get("prompt")).toBeNull();
|
||||
// The configured scope still goes out — the opt-out drops the SDK's
|
||||
// addition, not what the user asked for.
|
||||
expect(scope.split(/\s+/)).toContain("mcp");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue