Skip to content

Commit 76d7efb

Browse files
committed
fix(auth): MCP endpoint accepts a case-insensitive Bearer scheme
The MCP HTTP middleware only accepted an Authorization value starting with exactly "Bearer ", so a client sending "bearer <token>" (valid per RFC 9110 §11.1) was rejected — and logged with missing_bearer_token, the same reason as a request with no header at all, so the two could not be told apart. The same case-sensitive parse in EnvironmentAuth.parseBearerToken meant OAuth client tokens (the ChatGPT-connector path) stayed broken even with the middleware fixed. The middleware now reads the credential through readBearerCredential, which matches the scheme case-insensitively and classifies a rejection as missing_authorization_header, unsupported_authorization_scheme, missing_bearer_token, or unknown_or_expired_token; the warning carries that reason and never the credential. parseBearerToken matches the scheme case-insensitively for MCP client sessions and every other bearer-authenticated HTTP route. Failing tests first: on main, "bearer <provider token>" on /mcp returned 401 (expected 200) and authenticateMcpClient failed with ServerAuthMissingCredentialError. Both pass with the fix, and the middleware test asserts the four rejection reasons in order. Fixes #23710 (upstream). Model: GLM-5.3 (ZCode)
1 parent 6e79211 commit 76d7efb

4 files changed

Lines changed: 82 additions & 19 deletions

File tree

‎apps/server/src/auth/EnvironmentAuth.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
269269
}).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))),
270270
);
271271

272+
it.effect("authenticates MCP clients with a case-insensitive bearer scheme", () =>
273+
Effect.gen(function* () {
274+
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
275+
const issued = yield* serverAuth.issueMcpClientSession({
276+
label: "lowercase-scheme-test",
277+
access: "read-only",
278+
client: { deviceType: "bot" },
279+
});
280+
281+
const client = yield* serverAuth.authenticateMcpClient({
282+
cookies: {},
283+
headers: { authorization: `bearer ${issued.token}` },
284+
} as never);
285+
286+
expect(client.sessionId).toBe(issued.sessionId);
287+
expect(client.access).toBe("read-only");
288+
}).pipe(Effect.provide(layerEnvironmentAuth())),
289+
);
290+
272291
it.effect("preserves pairing grants after rejecting scopes they do not grant", () =>
273292
Effect.gen(function* () {
274293
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;

‎apps/server/src/auth/EnvironmentAuth.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,6 @@ type BootstrapExchangeResult = {
588588
readonly expireNormalCookie?: boolean;
589589
};
590590

591-
const AUTHORIZATION_PREFIX = "Bearer ";
592591
const DPOP_AUTHORIZATION_PREFIX = "DPoP ";
593592
const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";
594593

@@ -638,10 +637,13 @@ const mapSessionVerificationErrors = <A, R>(
638637

639638
function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
640639
const header = request.headers["authorization"];
641-
if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) {
640+
if (typeof header !== "string") return null;
641+
// RFC 9110 §11.1: the auth-scheme is case-insensitive, so "bearer" must parse like "Bearer".
642+
const spaceAt = header.indexOf(" ");
643+
if (spaceAt <= 0 || header.slice(0, spaceAt).toLowerCase() !== "bearer") {
642644
return null;
643645
}
644-
const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
646+
const token = header.slice(spaceAt + 1).trim();
645647
return token.length > 0 ? token : null;
646648
}
647649

‎apps/server/src/mcp/McpHttpServer.test.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import * as Deferred from "effect/Deferred";
1414
import * as Effect from "effect/Effect";
1515
import * as Layer from "effect/Layer";
16+
import * as Logger from "effect/Logger";
1617
import * as Schema from "effect/Schema";
1718
import * as Stream from "effect/Stream";
1819
import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
@@ -689,8 +690,15 @@ it.effect("terminates HTTP MCP sessions with DELETE", () =>
689690
).pipe(Effect.provide(NodeHttpServer.layerTest)),
690691
);
691692

692-
it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () =>
693-
Effect.scoped(
693+
it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => {
694+
const rejectionReasons: Array<string> = [];
695+
const captureRejections = Logger.make((options) => {
696+
if (options.logLevel === "Warn") {
697+
const parts = options.message as ReadonlyArray<unknown>;
698+
rejectionReasons.push((parts[1] as { readonly reason: string }).reason);
699+
}
700+
});
701+
return Effect.scoped(
694702
Effect.gen(function* () {
695703
const providerToken = "providerTokenWithoutDots";
696704
const clientToken = "client-payload.client-signature";
@@ -759,11 +767,11 @@ it.effect("admits provider and OAuth client credentials and points only clients
759767
Layer.build,
760768
);
761769
const httpClient = yield* HttpClient.HttpClient;
762-
const call = (token: string | undefined) =>
770+
const call = (token: string | undefined, scheme = "Bearer") =>
763771
Effect.gen(function* () {
764772
const headers = {
765773
accept: "application/json, text/event-stream",
766-
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
774+
...(token === undefined ? {} : { authorization: `${scheme} ${token}` }),
767775
};
768776
const initialize = yield* httpClient.post("/mcp", {
769777
headers,
@@ -788,9 +796,12 @@ it.effect("admits provider and OAuth client credentials and points only clients
788796

789797
expect((yield* call(providerToken)).status).toBe(200);
790798
expect((yield* call(clientToken)).status).toBe(200);
799+
// RFC 9110 §11.1: the auth-scheme is case-insensitive, so "bearer" authenticates too.
800+
expect((yield* call(providerToken, "bearer")).status).toBe(200);
791801
expect(seen.map((scope) => scope.requestNamespace)).toEqual([
792802
"provider-session",
793803
"client:session-1",
804+
"provider-session",
794805
]);
795806

796807
const missing = yield* call(undefined);
@@ -799,13 +810,26 @@ it.effect("admits provider and OAuth client credentials and points only clients
799810
/^Bearer resource_metadata="http:\/\/[^"]+\/\.well-known\/oauth-protected-resource\/mcp"$/,
800811
);
801812

813+
const wrongScheme = yield* call("credential", "Basic");
814+
expect(wrongScheme.status).toBe(401);
815+
expect(wrongScheme.headers["www-authenticate"]).toBe(missing.headers["www-authenticate"]);
816+
802817
const expiredClient = yield* call("stale-payload.stale-signature");
803818
expect(expiredClient.headers["www-authenticate"]).toContain("resource_metadata=");
804819
expect(expiredClient.headers["www-authenticate"]).toContain('error="invalid_token"');
805820

806821
const deadProvider = yield* call("deadProviderToken");
807822
expect(deadProvider.status).toBe(401);
808823
expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"');
824+
825+
expect(rejectionReasons).toEqual([
826+
"missing_authorization_header",
827+
"unsupported_authorization_scheme",
828+
"unknown_or_expired_token",
829+
"unknown_or_expired_token",
830+
]);
809831
}),
810-
).pipe(Effect.provide(NodeHttpServer.layerTest)),
811-
);
832+
).pipe(
833+
Effect.provide(Layer.mergeAll(Logger.layer([captureRejections]), NodeHttpServer.layerTest)),
834+
);
835+
});

‎apps/server/src/mcp/McpHttpServer.ts‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,29 +153,47 @@ const normalizeMcpHttpResponse = (
153153
// Session tokens are `<payload>.<signature>`; registry tokens are a bare base64url secret.
154154
const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes(".");
155155

156+
/**
157+
* Reads the Bearer credential from an Authorization header. RFC 9110 §11.1 makes the auth-scheme
158+
* case-insensitive, so "bearer" must authenticate like "Bearer", and the reason records whether
159+
* a rejected request sent no header, another scheme, or an empty token.
160+
*/
161+
const readBearerCredential = (
162+
authorization: string | undefined,
163+
):
164+
| { readonly reason: "missing_authorization_header"; readonly token: "" }
165+
| { readonly reason: "unsupported_authorization_scheme"; readonly token: "" }
166+
| { readonly reason: "missing_bearer_token"; readonly token: "" }
167+
| { readonly reason: "unknown_or_expired_token"; readonly token: string } => {
168+
if (authorization === undefined) return { reason: "missing_authorization_header", token: "" };
169+
const spaceAt = authorization.indexOf(" ");
170+
const scheme = (spaceAt === -1 ? authorization : authorization.slice(0, spaceAt)).toLowerCase();
171+
if (scheme !== "bearer") return { reason: "unsupported_authorization_scheme", token: "" };
172+
const token = spaceAt === -1 ? "" : authorization.slice(spaceAt + 1).trim();
173+
return token.length > 0
174+
? { reason: "unknown_or_expired_token", token }
175+
: { reason: "missing_bearer_token", token: "" };
176+
};
177+
156178
const makeMcpAuthMiddleware = Effect.gen(function* () {
157179
const registry = yield* McpSessionRegistry.McpSessionRegistry;
158180
const clients = yield* Effect.serviceOption(McpClientAuthenticator);
159181
return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) {
160182
const request = yield* HttpServerRequest.HttpServerRequest;
161-
const authorization = request.headers.authorization;
162-
const token =
163-
authorization?.startsWith("Bearer ") === true
164-
? authorization.slice("Bearer ".length).trim()
165-
: "";
183+
const credential = readBearerCredential(request.headers.authorization);
166184
const invocation =
167-
(yield* registry.resolve(token)) ??
168-
(Option.isSome(clients) && token.length > 0
185+
(yield* registry.resolve(credential.token)) ??
186+
(Option.isSome(clients) && credential.token.length > 0
169187
? yield* clients.value.authenticate(request)
170188
: undefined);
171189
if (!invocation) {
172190
yield* Effect.logWarning("rejected MCP request with an unusable credential", {
173-
reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token",
191+
reason: credential.reason,
174192
});
175193
return unauthorized({
176194
request,
177-
presentedToken: token.length > 0,
178-
offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token),
195+
presentedToken: credential.token.length > 0,
196+
offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(credential.token),
179197
});
180198
}
181199
return yield* httpEffect.pipe(

0 commit comments

Comments
 (0)