Address @claude review: issuer-stamp source gating + clear/activeIssuer + phase over-match
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

- SEP-2352: stamp the issuer only when the credential actually came from the
  byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated
  slot alongside a surviving legacy field would otherwise stamp a legacy (possibly
  cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch.
- Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer`
  (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion).
- oauthNetworkPhase matches register/token/authorize as the final path segment
  (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer
  over-match.
- Tests for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-07-16 15:48:31 -04:00
commit 111d50b325
3 changed files with 102 additions and 9 deletions

View file

@ -533,6 +533,63 @@ describe("BrowserOAuthStorage", () => {
});
});
it("does not stamp a legacy fallback credential when the issuer slot lacks it", async () => {
// Legacy unkeyed client info survives, but a byIssuer slot exists for
// issuerA holding only tokens (no clientInformation). Reading client info
// for issuerA must fall back to the legacy value WITHOUT stamping it with
// issuerA — the legacy credential may have been minted by a different AS.
await storage.saveClientInformation(
testServerUrl,
{ client_id: "legacy-client" },
{ registrationKind: "dcr" },
);
await storage.saveTokens(
testServerUrl,
{ access_token: "tok-a", token_type: "Bearer" },
{ issuer: issuerA },
);
const clientInfo = await storage.getClientInformation(
testServerUrl,
false,
issuerA,
);
// No issuer stamp — so the SDK's discardIfIssuerMismatch stays engaged.
expect(clientInfo).toEqual({ client_id: "legacy-client" });
// Symmetric case: legacy tokens survive, slot has only clientInformation.
await storage.clear(testServerUrl);
await storage.saveTokens(testServerUrl, {
access_token: "legacy-tok",
token_type: "Bearer",
});
await storage.saveClientInformation(
testServerUrl,
{ client_id: "client-a" },
{ registrationKind: "dcr", issuer: issuerA },
);
const tokens = await storage.getTokens(testServerUrl, issuerA);
expect(tokens).toEqual({
access_token: "legacy-tok",
token_type: "Bearer",
});
});
it("does not promote a cleared issuer to the active (ctx-less) slot", async () => {
await storage.saveTokens(
testServerUrl,
{ access_token: "tok-a", token_type: "Bearer" },
{ issuer: issuerA },
);
// Clearing a *different* issuer must not make it the active issuer.
await storage.clearTokens(testServerUrl, issuerB);
// The ctx-less (per-request bearer) read still resolves issuerA's token.
expect(await storage.getTokens(testServerUrl)).toMatchObject({
access_token: "tok-a",
});
});
it("clears every issuer's tokens/registration when no issuer is given", async () => {
await storage.saveTokens(
testServerUrl,

View file

@ -45,6 +45,20 @@ describe("oauthNetworkPhase", () => {
expect(oauthNetworkPhase("https://mcp.example.com/mcp")).toBeUndefined();
});
it("matches the endpoint as the final path segment, not a nested one", () => {
// A nested segment must not classify as the OAuth endpoint...
expect(
oauthNetworkPhase("https://as.example.com/oauth/token/refresh"),
).toBeUndefined();
expect(
oauthNetworkPhase("https://gw.example.com/api/register/foo"),
).toBeUndefined();
// ...but a trailing slash on the real endpoint is tolerated.
expect(oauthNetworkPhase("https://as.example.com/oauth/token/")).toBe(
"token",
);
});
it("tolerates a non-absolute URL by stripping the query", () => {
expect(oauthNetworkPhase("/register?foo=bar")).toBe("registration");
expect(oauthNetworkPhase("not a url")).toBeUndefined();

View file

@ -41,13 +41,16 @@ export function oauthNetworkPhase(
) {
return "discovery";
}
if (path.endsWith("/register") || path.includes("/register/")) {
// Match the endpoint as the final path segment (trailing slash tolerated) so a
// nested path like `/token/refresh` or `/api/register/foo` is not misclassified.
const endpoint = path.replace(/\/+$/, "");
if (endpoint.endsWith("/register")) {
return "registration";
}
if (path.endsWith("/token") || path.includes("/token/")) {
if (endpoint.endsWith("/token")) {
return "token";
}
if (path.endsWith("/authorize") || path.includes("/authorize/")) {
if (endpoint.endsWith("/authorize")) {
return "authorize";
}
return undefined;