fix(auth): refuse a grant whose authorization server cannot be established (#2144)

Copilot review round 15.

The issuer check still failed open for the legacy unkeyed grant. "Unkeyed"
means its authorization server is UNKNOWN, not that the cached endpoint is
proven to own it: server metadata is a single slot a later discovery
overwrites, while a pre-SEP-2352 token survives until the first issuer-stamped
save. So a clear could post an old bearer credential to an authorization server
that never minted it.

Such a grant is now cleared without being revoked, and reported as such with a
message saying re-authorizing makes future clears revocable. The cost is that
a pre-issuer-binding entry gets no revocation — which is exactly the behavior
it had before this feature existed, so nothing regresses — and it earns
revocation back on the next authorization. Not sending is the only answer that
cannot be wrong.

Every seed in the suite is now issuer-bound, which is what a real flow writes;
the two tests that were about the legacy grant now assert the refusal, since
that is the behavior worth pinning.

Also renamed the CLI test that still described the pre-round-13 ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-28 06:18:58 -04:00
commit a4ee263365
2 changed files with 190 additions and 77 deletions

View file

@ -8,6 +8,8 @@ import {
} from "@inspector/core/auth/node/storage-node.js";
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";
const AS_ISSUER = "https://as.example.com";
describe("clearStoredAuthForRelogin", () => {
let dir: string | undefined;
let prevPath: string | undefined;
@ -77,17 +79,25 @@ describe("clearStoredAuthForRelogin", () => {
file,
JSON.stringify({
servers: {
// Issuer-bound (SEP-2352), matching the cached metadata below: an
// unkeyed grant records no authorization server and is refused, so
// seeding one would make these cases assert that instead.
"https://example.com/mcp": {
tokens: {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
activeIssuer: AS_ISSUER,
byIssuer: {
[AS_ISSUER]: {
tokens: {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
},
},
},
serverMetadata: {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
revocation_endpoint: "https://as.example.com/revoke",
issuer: AS_ISSUER,
authorization_endpoint: `${AS_ISSUER}/authorize`,
token_endpoint: `${AS_ISSUER}/token`,
revocation_endpoint: `${AS_ISSUER}/revoke`,
response_types_supported: ["code"],
},
...over,
@ -111,17 +121,22 @@ describe("clearStoredAuthForRelogin", () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-two-"));
const file = path.join(dir, "oauth.json");
const metadata = {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
revocation_endpoint: "https://as.example.com/revoke",
issuer: AS_ISSUER,
authorization_endpoint: `${AS_ISSUER}/authorize`,
token_endpoint: `${AS_ISSUER}/token`,
revocation_endpoint: `${AS_ISSUER}/revoke`,
response_types_supported: ["code"],
};
const entry = (refresh: string) => ({
tokens: {
access_token: `a-${refresh}`,
token_type: "Bearer",
refresh_token: refresh,
activeIssuer: AS_ISSUER,
byIssuer: {
[AS_ISSUER]: {
tokens: {
access_token: `a-${refresh}`,
token_type: "Bearer",
refresh_token: refresh,
},
},
},
serverMetadata: metadata,
});
@ -141,7 +156,7 @@ describe("clearStoredAuthForRelogin", () => {
resetNodeOAuthStorageCache();
}
it("revokes the stored grant before deleting it", async () => {
it("revokes the stored grant, having snapshotted it before the delete", async () => {
const file = seed();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
@ -194,10 +209,10 @@ describe("clearStoredAuthForRelogin", () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-both-"));
const file = path.join(dir, "oauth.json");
const metadata = {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
revocation_endpoint: "https://as.example.com/revoke",
issuer: AS_ISSUER,
authorization_endpoint: `${AS_ISSUER}/authorize`,
token_endpoint: `${AS_ISSUER}/token`,
revocation_endpoint: `${AS_ISSUER}/revoke`,
response_types_supported: ["code"],
};
fs.writeFileSync(
@ -206,19 +221,29 @@ describe("clearStoredAuthForRelogin", () => {
servers: {
// The raw spelling the transport keyed by, holding a STALE grant.
"https://example.com": {
tokens: {
access_token: "stale-a",
token_type: "Bearer",
refresh_token: "stale-r",
activeIssuer: AS_ISSUER,
byIssuer: {
[AS_ISSUER]: {
tokens: {
access_token: "stale-a",
token_type: "Bearer",
refresh_token: "stale-r",
},
},
},
serverMetadata: metadata,
},
// The normalised spelling, holding the CURRENT grant.
"https://example.com/": {
tokens: {
access_token: "live-a",
token_type: "Bearer",
refresh_token: "live-r",
activeIssuer: AS_ISSUER,
byIssuer: {
[AS_ISSUER]: {
tokens: {
access_token: "live-a",
token_type: "Bearer",
refresh_token: "live-r",
},
},
},
serverMetadata: metadata,
},
@ -334,8 +359,11 @@ describe("clearStoredAuthForRelogin", () => {
JSON.stringify({
servers: {
"https://example.com/mcp": {
// No `token_type` — fails OAuthTokensSchema.
tokens: { access_token: 42 },
activeIssuer: AS_ISSUER,
byIssuer: {
// No `token_type` — fails OAuthTokensSchema.
[AS_ISSUER]: { tokens: { access_token: 42 } },
},
},
},
idpSessions: {},

View file

@ -21,7 +21,8 @@ import { resetNodeOAuthStorageCache } from "@inspector/core/auth/node/storage-no
import { runCli } from "./helpers/cli-runner.js";
const SERVER_URL = "https://example.com/mcp";
const REVOKE_URL = "https://as.example.com/revoke";
const AS_ISSUER = "https://as.example.com";
const REVOKE_URL = `${AS_ISSUER}/revoke`;
let dir: string | undefined;
let prevPath: string | undefined;
@ -46,16 +47,23 @@ function seedStore(): void {
file,
JSON.stringify({
servers: {
// Issuer-bound (SEP-2352) and matching the cached metadata: an unkeyed
// grant records no authorization server and is deliberately refused.
[SERVER_URL]: {
tokens: {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
activeIssuer: AS_ISSUER,
byIssuer: {
[AS_ISSUER]: {
tokens: {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
},
},
},
serverMetadata: {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
issuer: AS_ISSUER,
authorization_endpoint: `${AS_ISSUER}/authorize`,
token_endpoint: `${AS_ISSUER}/token`,
revocation_endpoint: REVOKE_URL,
response_types_supported: ["code"],
},