import { describe, expect, it } from "@effect/vitest"; import { authToolFailure } from "./auth-tool-failure"; import { detectInsufficientScope } from "./insufficient-scope"; describe("detectInsufficientScope", () => { it("detects Google's ErrorInfo reason nested in an error body", () => { const body = { error: { code: 403, message: "Request had insufficient authentication scopes.", status: "PERMISSION_DENIED", details: [ { "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT", domain: "googleapis.com", metadata: { service: "drive.googleapis.com" }, }, ], }, }; expect(detectInsufficientScope({ body })).toEqual({ requiredScopes: [] }); }); it("detects the RFC 6750 error body", () => { expect(detectInsufficientScope({ body: { error: "insufficient_scope" } })).toEqual({ requiredScopes: [], }); }); it("reads required scopes from a WWW-Authenticate challenge", () => { expect( detectInsufficientScope({ body: null, headers: { "www-authenticate": 'Bearer realm="example", error="insufficient_scope", scope="files.read files.meta"', }, }), ).toEqual({ requiredScopes: ["files.read", "files.meta"] }); }); it("detects the signal inside a JSON text body", () => { expect(detectInsufficientScope({ body: '{"error":"insufficient_scope"}' })).toEqual({ requiredScopes: [], }); }); it("never classifies non-JSON text, even when it embeds example JSON", () => { expect( detectInsufficientScope({ body: "Proxy note: error=insufficient_scope was returned upstream", }), ).toBeNull(); expect( detectInsufficientScope({ body: 'The docs show {"error":"insufficient_scope"} as an example response', }), ).toBeNull(); }); it("ignores quoted challenge parameter values that embed the error token", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer error_description="Example: error=insufficient_scope for missing grants"', }, }), ).toBeNull(); }); it("ignores prose that merely mentions the tokens", () => { expect( detectInsufficientScope({ body: { error: { message: "If the token lacks access you may see insufficient_scope or ACCESS_TOKEN_SCOPE_INSUFFICIENT in provider docs", }, }, }), ).toBeNull(); expect( detectInsufficientScope({ body: "Consult the OAuth guide about insufficient_scope errors", }), ).toBeNull(); }); it("ignores the tokens under other field names and inside data lists", () => { expect( detectInsufficientScope({ body: { supportedErrors: ["invalid_token", "insufficient_scope"] }, }), ).toBeNull(); expect(detectInsufficientScope({ body: { code: "insufficient_scope" } })).toBeNull(); }); it("ignores look-alike challenge parameters and values", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer x-error="insufficient_scope"' }, }), ).toBeNull(); expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer error="insufficient_scope_extra"' }, }), ).toBeNull(); }); it("consumes quoted-pairs, so an escaped quote cannot fabricate a parameter", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer error_description="Example: \\"error=insufficient_scope"', }, }), ).toBeNull(); }); it("never discovers a scheme inside another challenge's quoted value", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": 'Basic error_description="Proxy saw Bearer error=insufficient_scope upstream"', }, }), ).toBeNull(); expect( detectInsufficientScope({ headers: { "www-authenticate": 'Digest realm="x", qop="auth Bearer error=insufficient_scope", nonce="n"', }, }), ).toBeNull(); // A Basic challenge followed by a harmless real Bearer challenge: the // Bearer params are read, and they do not include the Basic params. expect( detectInsufficientScope({ headers: { "www-authenticate": 'Basic error=insufficient_scope, Bearer realm="api"', }, }), ).toBeNull(); }); it("only reads the Bearer scheme's parameters", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": 'Basic realm="example", error=insufficient_scope' }, }), ).toBeNull(); // Multi-challenge: params after the NEXT scheme are not Bearer's. expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer realm="api", Basic error=insufficient_scope', }, }), ).toBeNull(); }); it("evaluates repeated Bearer challenges independently", () => { // The second Bearer challenge carries the signal; the first must not // shadow it (params are per challenge, not first-wins across the header). expect( detectInsufficientScope({ headers: { "www-authenticate": "Bearer error=invalid_token, Basic realm=x, Bearer error=insufficient_scope", }, }), ).toEqual({ requiredScopes: [] }); // And a scope attr from an unrelated Bearer challenge does not leak into // the one that matched. expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer scope="other.scope", Bearer error=insufficient_scope', }, }), ).toEqual({ requiredScopes: [] }); }); it("recognizes a new scheme only at a comma boundary", () => { // Comma-less run-ons are malformed and must not fabricate a Bearer // challenge out of a bare word. expect( detectInsufficientScope({ headers: { "www-authenticate": "Basic realm=x Bearer error=insufficient_scope" }, }), ).toBeNull(); expect( detectInsufficientScope({ headers: { "www-authenticate": "Basic error_description=Proxy Bearer error=insufficient_scope", }, }), ).toBeNull(); }); it("steps over a token68 blob to a later legitimate Bearer challenge", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": "Negotiate abc/def==, Bearer error=insufficient_scope", }, }), ).toEqual({ requiredScopes: [] }); }); it("never classifies malformed headers", () => { // Unterminated quoted-string. expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer error="insufficient_scope' }, }), ).toBeNull(); // Quoted value run into the next token with no separator. expect( detectInsufficientScope({ headers: { "www-authenticate": 'Basic realm="x"Bearer error=insufficient_scope' }, }), ).toBeNull(); }); it("accepts BWS around the auth-param equals sign", () => { for (const header of [ "Bearer error =insufficient_scope", "Bearer error= insufficient_scope", 'Bearer error = "insufficient_scope"', ]) { expect(detectInsufficientScope({ headers: { "www-authenticate": header } })).toEqual({ requiredScopes: [], }); } }); it("rejects params attached to token68 or scheme-only challenges", () => { for (const header of [ "Bearer a=, error=insufficient_scope", "Bearer abc, error=insufficient_scope", "Bearer, error=insufficient_scope", 'Bearer realm="x" error=insufficient_scope', ]) { expect( detectInsufficientScope({ headers: { "www-authenticate": header } }), header, ).toBeNull(); } }); it("accepts full HTTP token characters in schemes and param names", () => { for (const header of [ "Bearer foo!=bar, error=insufficient_scope", "Bearer x#=bar, error=insufficient_scope", "Bearer x|=bar, error=insufficient_scope", "Foo! realm=x, Bearer error=insufficient_scope", ]) { expect(detectInsufficientScope({ headers: { "www-authenticate": header } }), header).toEqual({ requiredScopes: [], }); } }); it("rejects empty values, non-token values, and duplicate param names", () => { for (const header of [ "Bearer realm =, error=insufficient_scope", "Bearer realm=;, error=insufficient_scope", "Bearer error=insufficient_scope, error=invalid_token", // `/` and `=` are token68-only; schemes, param names, and unquoted // values are HTTP tokens. "Bearer foo/bar=baz, error=insufficient_scope", "Foo/Bar realm=x, Bearer error=insufficient_scope", "Bearer realm=foo/bar, error=insufficient_scope", "Bearer realm=foo=bar, error=insufficient_scope", // `!` is token-only, not token68. "Bearer abc!==, error=insufficient_scope", ]) { expect( detectInsufficientScope({ headers: { "www-authenticate": header } }), header, ).toBeNull(); } }); it("tolerates real-provider header quirks without weakening the signal params", () => { // Stripe (live snapshot): unquoted resource_metadata URL. expect( detectInsufficientScope({ headers: { "www-authenticate": "Bearer resource_metadata=https://mcp.stripe.com/.well-known/oauth-protected-resource, error=insufficient_scope", }, }), ).toEqual({ requiredScopes: [] }); // Sentry (live snapshot): duplicated resource_metadata param. expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer realm="OAuth", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource", error="insufficient_scope", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource"', }, }), ).toEqual({ requiredScopes: [] }); // But a duplicated SIGNAL param still fails closed. expect( detectInsufficientScope({ headers: { "www-authenticate": "Bearer error=insufficient_scope, error=invalid_token", }, }), ).toBeNull(); }); it("accepts comma-separated params within one Bearer challenge", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": 'Bearer realm="api", error="insufficient_scope", scope="a.b"', }, }), ).toEqual({ requiredScopes: ["a.b"] }); }); it("accepts an unquoted RFC 6750 challenge value", () => { expect( detectInsufficientScope({ headers: { "www-authenticate": "Bearer error=insufficient_scope" }, }), ).toEqual({ requiredScopes: [] }); }); it("returns null for an ordinary 403 body", () => { expect( detectInsufficientScope({ body: { error: { status: "PERMISSION_DENIED", message: "Caller lacks permission" } }, headers: { "www-authenticate": 'Bearer realm="example", error="invalid_token"' }, }), ).toBeNull(); }); it("returns null for empty input", () => { expect(detectInsufficientScope({})).toBeNull(); }); }); describe("authToolFailure recovery for oauth_scope_insufficient", () => { const recoveryOf = (result: ReturnType) => { const details = (result as { error: { details: { recovery: Record } } }).error .details; return details.recovery; }; it("omits the oauth.start hint, which would re-run the identical grant", () => { const recovery = recoveryOf( authToolFailure({ code: "oauth_scope_insufficient", message: "scope shortfall" }), ); expect(recovery.startOAuthTool).toBeUndefined(); expect(recovery.oauthInstructions).toBeUndefined(); expect(recovery.scopeInstructions).toContain("does not cover the scope"); expect(recovery.listConnectionsTool).toBe("executor.coreTools.connections.list"); }); it("keeps the full recovery block for connection_rejected", () => { const recovery = recoveryOf( authToolFailure({ code: "connection_rejected", message: "rejected" }), ); expect(recovery.startOAuthTool).toBe("executor.coreTools.oauth.start"); expect(recovery.oauthInstructions).toBeDefined(); }); });