Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitb5a7bb3

fix(mcp): RFC 6750 invalid_token challenge — break the dead-token reconnect loop

fix(mcp): RFC 6750 invalid_token challenge — break the dead-token reconnect loop

A presented-but-rejected bearer (expired/revoked/unknown glct_/glc_) was
silently downgraded to anonymous by softAuth, and the /mcp challenge never
carried error="invalid_token" — so clients could not tell 'token died,
refresh it' from 'never authenticated'. Worse, the open handshake let
/mcp reconnect report false success on a dead token, looping forever on
tools/call while the fully-working refresh_token grant sat unused.

- middleware/auth.ts (locked file, owner-approved 2026-07-14): softAuth
  now sets bearerInvalid when a gluecron-shaped bearer is rejected;
  additive only, valid tokens/PATs/cookies unchanged.
- routes/mcp.ts: invalid bearer now 401s on EVERY method (including
  initialize) with WWW-Authenticate: Bearer error="invalid_token";
  anonymous challenge unchanged (no error= attribute); GET /mcp
  discovery gets the same signal, anonymous discovery stays open.
- tests: new invalid-bearer describe block + tightened anonymous
  challenge assertion (must NOT contain error=).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccanty labs committed on July 14, 2026Parent: 94b5edf
3 files changed+1406b5a7bb304797bd91d4f86fb6da39ede0e30fb247
3 changed files+140−6
Modifiedsrc/__tests__/mcp.test.ts+72−0View fileUnifiedSplit
232232 expect(wwwAuth).toContain("Bearer");
233233 expect(wwwAuth).toContain("resource_metadata=");
234234 expect(wwwAuth).toContain("/.well-known/oauth-protected-resource");
235 // RFC 6750: the ANONYMOUS challenge must NOT carry `error=` — its absence
236 // is what tells the client "no credentials, start first-time auth" rather
237 // than "your token died, refresh it".
238 expect(wwwAuth).not.toContain("error=");
235239 const body = (await res.json()) as any;
236240 expect(body.error.code).toBe(-32001);
237241 expect(body.id).toBe(7); // echoes the request id
264268 expect(res.status).toBe(200);
265269 });
266270});
271
272describe("HTTP route — POST /mcp invalid bearer challenge (RFC 6750)", () => {
273 // A presented-but-dead bearer (expired / revoked / unknown) must fail
274 // LOUDLY on every method — including the handshake — with
275 // `error="invalid_token"`, so clients run the refresh grant or re-auth
276 // instead of holding a false-healthy connection. Regression guard for the
277 // 2026-07-14 "token expired" reconnect loop.
278 const rpc = (method: string, bearer: string, id = 9) =>
279 app.request("/mcp", {
280 method: "POST",
281 headers: {
282 "content-type": "application/json",
283 authorization: `Bearer ${bearer}`,
284 },
285 body: JSON.stringify({ jsonrpc: "2.0", id, method, params: {} }),
286 });
287
288 const expectInvalidToken = async (res: Response) => {
289 expect(res.status).toBe(401);
290 const wwwAuth = res.headers.get("www-authenticate") || "";
291 expect(wwwAuth).toContain('error="invalid_token"');
292 expect(wwwAuth).toContain("resource_metadata=");
293 const body = (await res.json()) as any;
294 expect(body.error.code).toBe(-32001);
295 expect(String(body.error.message)).toMatch(/expired|invalid/i);
296 };
297
298 it("challenges an unknown glct_ token on tools/call", async () => {
299 await expectInvalidToken(await rpc("tools/call", "glct_deadbeef"));
300 });
301
302 it("challenges an unknown glct_ token on initialize (breaks the reconnect loop)", async () => {
303 await expectInvalidToken(await rpc("initialize", "glct_deadbeef"));
304 });
305
306 it("challenges an unknown glct_ token on tools/list", async () => {
307 await expectInvalidToken(await rpc("tools/list", "glct_deadbeef"));
308 });
309
310 it("challenges an unknown glc_ PAT on initialize", async () => {
311 await expectInvalidToken(await rpc("initialize", "glc_deadbeef"));
312 });
313
314 it("echoes the request id in the invalid_token error", async () => {
315 const res = await rpc("initialize", "glct_deadbeef", 42);
316 const body = (await res.json()) as any;
317 expect(body.id).toBe(42);
318 });
319
320 it("challenges GET /mcp discovery with a dead bearer, plain-JSON body", async () => {
321 const res = await app.request("/mcp", {
322 headers: { authorization: "Bearer glct_deadbeef" },
323 });
324 expect(res.status).toBe(401);
325 expect(res.headers.get("www-authenticate") || "").toContain(
326 'error="invalid_token"'
327 );
328 const body = (await res.json()) as any;
329 expect(body.error).toBe("invalid_token");
330 });
331
332 it("a non-gluecron bearer scheme is ignored (anonymous handshake works)", async () => {
333 // e.g. an unrelated `Bearer xyz` (no glct_/glc_ prefix) must not trip the
334 // invalid-token gate — softAuth only flags gluecron-shaped tokens.
335 const res = await rpc("tools/list", "some-other-token");
336 expect(res.status).toBe(200);
337 });
338});
Modifiedsrc/middleware/auth.ts+11−0View fileUnifiedSplit
3636 authMethod?: "token" | "session" | "none";
3737 /** Set by API-auth middleware — scopes carried by the PAT or session. */
3838 tokenScopes?: string[];
39 /**
40 * Set by softAuth when an `Authorization: Bearer glct_/glc_` token was
41 * presented but rejected (expired, revoked, or unknown). Routes use this
42 * to emit an RFC 6750 `error="invalid_token"` challenge instead of
43 * treating the caller as anonymous.
44 */
45 bearerInvalid?: boolean;
3946 };
4047};
4148
142149 c.set("oauthAppId", result.appId);
143150 return next();
144151 }
152 // Token presented but rejected — stay soft (cookie fallback still
153 // runs), but flag it so routes can signal invalid_token per RFC 6750.
154 c.set("bearerInvalid", true);
145155 } else if (bearer.startsWith("glc_")) {
146156 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
147157 const result = await loadUserFromPat(bearer);
150160 c.set("oauthScopes", result.scopes);
151161 return next();
152162 }
163 c.set("bearerInvalid", true);
153164 }
154165 }
155166
Modifiedsrc/routes/mcp.ts+57−6View fileUnifiedSplit
4848 * all satisfy it, so token clients (.mcp.json, CLI, VS Code extension) are
4949 * unaffected. GET /mcp discovery also stays open.
5050 */
51function authChallenge(c: Parameters<Parameters<typeof mcp.post>[1]>[0], id: unknown) {
51function authChallenge(
52 c: Parameters<Parameters<typeof mcp.post>[1]>[0],
53 id: unknown,
54 opts?: { invalidToken?: boolean }
55) {
5256 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
57 if (opts?.invalidToken) {
58 // RFC 6750 §3.1: `error="invalid_token"` tells the client its token is
59 // expired/revoked, so it runs the refresh_token grant (or re-authorizes)
60 // instead of replaying the dead token forever. The anonymous challenge
61 // below deliberately omits `error=` — its absence means "no credentials
62 // at all, start first-time auth".
63 c.header(
64 "WWW-Authenticate",
65 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
66 );
67 return c.json(
68 {
69 jsonrpc: "2.0",
70 id: id ?? null,
71 error: {
72 code: -32001,
73 message: "Invalid or expired access token — refresh or re-authorize",
74 },
75 },
76 401
77 );
78 }
5379 c.header("WWW-Authenticate", `Bearer resource_metadata="${rm}"`);
5480 return c.json(
5581 {
6187 );
6288}
6389
90/** Extract the JSON-RPC id from a single (non-batch) envelope, else null. */
91function idOf(body: unknown): unknown {
92 return !Array.isArray(body) && body && typeof body === "object"
93 ? (body as { id?: unknown }).id ?? null
94 : null;
95}
96
6497/** True when the JSON-RPC envelope (single or batch) invokes a tool. */
6598function invokesTool(body: unknown): boolean {
6699 const isCall = (e: unknown): boolean =>
72105}
73106
74107mcp.get("/mcp", (c) => {
108 // A dead bearer on discovery gets the invalid_token signal too (plain JSON
109 // — this endpoint is not JSON-RPC). Anonymous discovery stays open.
110 if (c.get("bearerInvalid")) {
111 const rm = `${config.appBaseUrl}/.well-known/oauth-protected-resource`;
112 c.header(
113 "WWW-Authenticate",
114 `Bearer error="invalid_token", error_description="The access token is expired or invalid", resource_metadata="${rm}"`
115 );
116 return c.json(
117 { error: "invalid_token", message: "Access token expired or invalid — refresh or re-authorize" },
118 401
119 );
120 }
75121 const tools = defaultTools();
76122 return c.json({
77123 protocolVersion: MCP_PROTOCOL_VERSION,
114160 );
115161 }
116162
163 // A bearer token was presented but rejected (expired/revoked/unknown).
164 // Challenge EVERY method — including initialize — with invalid_token, so a
165 // client reconnecting on a dead token fails loudly and refreshes or
166 // re-authorizes, instead of getting a false-healthy handshake and then
167 // looping on tools/call. Truly-anonymous handshake stays open below.
168 if (c.get("bearerInvalid")) {
169 return authChallenge(c, idOf(body), { invalidToken: true });
170 }
171
117172 // Anonymous tool calls trigger the OAuth discovery challenge so connectors
118173 // sign in. The handshake (initialize/tools-list/ping/notifications) is left
119174 // open above so clients can introspect without a token.
120175 if (!user && invokesTool(body)) {
121 const id =
122 !Array.isArray(body) && body && typeof body === "object"
123 ? (body as { id?: unknown }).id ?? null
124 : null;
125 return authChallenge(c, id);
176 return authChallenge(c, idOf(body));
126177 }
127178
128179 if (Array.isArray(body)) {
129180