From 0a33bc8f96ae6b699bae438b2be883da2c6f71e8 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 5 Aug 2026 12:21:22 +0200 Subject: [PATCH 1/3] Fall back to access_token query param when token/info rejects Bearer header --- oauth-proxy.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/oauth-proxy.ts b/oauth-proxy.ts index 0bf415cf..a1c9410e 100644 --- a/oauth-proxy.ts +++ b/oauth-proxy.ts @@ -665,10 +665,22 @@ class GitLabOAuthServerProvider implements OAuthServerProvider { // ---- Verify access token ----------------------------------------------- async verifyAccessToken(token: string): Promise { - const res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, { + let res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, { headers: { Authorization: `Bearer ${token}` }, }); + if (res.status === 401) { + // Some GitLab instances sit behind an edge cache that strips the + // Authorization header on /oauth/* paths (observed on + // git.drupalcode.org, fronted by Varnish), so a valid token 401s + // here while working fine against /api/v4. Doorkeeper also accepts + // the RFC 6750 access_token query parameter — retry with that form + // before rejecting the token. + res = await fetch( + `${this._gitlabBaseUrl}/oauth/token/info?access_token=${encodeURIComponent(token)}` + ); + } + if (!res.ok) { throw new InvalidTokenError("Invalid or expired GitLab OAuth token"); } From 4b33084b059b692e051e576d240a921ab342b7d6 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 7 Aug 2026 08:37:45 +0200 Subject: [PATCH 2/3] Add regression tests for token/info access_token query param fallback --- test/mcp-oauth-tests.ts | 118 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/test/mcp-oauth-tests.ts b/test/mcp-oauth-tests.ts index a1cec216..101d6fac 100644 --- a/test/mcp-oauth-tests.ts +++ b/test/mcp-oauth-tests.ts @@ -507,6 +507,124 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => { } }); + test("verifyAccessToken falls back to access_token query param when Bearer gets 401", async () => { + // Simulates a GitLab behind an edge cache that strips the Authorization + // header on /oauth/* (e.g. git.drupalcode.org behind Varnish): the + // Bearer-header request 401s, the RFC 6750 query-param retry succeeds. + const TOKEN = "tok+en/with?special=chars&more"; // exercises URL encoding + const requests: { url: string; hasAuthHeader: boolean }[] = []; + + const { createServer } = await import("node:http"); + const stub = createServer((req, res) => { + requests.push({ url: req.url!, hasAuthHeader: "authorization" in req.headers }); + const url = new URL(req.url!, "http://localhost"); + if (url.searchParams.get("access_token") === TOKEN) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + resource_owner_id: 7, + scopes: ["api"], + expires_in_seconds: 3600, + application: { uid: "app-uid-abc" }, + created_at: Math.floor(Date.now() / 1000), + }) + ); + } else { + // Header-stripping edge: reject anything not using the query param + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid_token" })); + } + }); + + await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve)); + const addr = stub.address() as { port: number }; + const baseUrl = `http://127.0.0.1:${addr.port}`; + + try { + const { createGitLabOAuthProvider } = await import("../oauth-proxy.js"); + const provider = createGitLabOAuthProvider(baseUrl, "test-app-id"); + const authInfo = await provider.verifyAccessToken(TOKEN); + + assert.strictEqual(requests.length, 2, "exactly one retry after the 401"); + assert.ok(requests[0].hasAuthHeader, "first attempt uses the Authorization header"); + assert.ok( + !requests[0].url.includes("access_token="), + "first attempt does not use the query param" + ); + assert.ok( + requests[1].url.includes(`access_token=${encodeURIComponent(TOKEN)}`), + "retry carries the URL-encoded access_token query param" + ); + assert.strictEqual(authInfo.token, TOKEN, "token preserved through the fallback path"); + assert.strictEqual(authInfo.clientId, "app-uid-abc", "AuthInfo built from retry response"); + console.log(" ✓ verifyAccessToken falls back to query param after Bearer 401"); + } finally { + stub.close(); + } + }); + + test("verifyAccessToken does not retry when the Bearer request succeeds", async () => { + let requestCount = 0; + const { createServer } = await import("node:http"); + const stub = createServer((_req, res) => { + requestCount++; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + resource_owner_id: 7, + scopes: ["api"], + expires_in_seconds: 3600, + application: { uid: "app-uid-abc" }, + created_at: Math.floor(Date.now() / 1000), + }) + ); + }); + + await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve)); + const addr = stub.address() as { port: number }; + const baseUrl = `http://127.0.0.1:${addr.port}`; + + try { + const { createGitLabOAuthProvider } = await import("../oauth-proxy.js"); + const provider = createGitLabOAuthProvider(baseUrl, "test-app-id"); + await provider.verifyAccessToken("good-token"); + + assert.strictEqual(requestCount, 1, "no retry on a 200 response"); + console.log(" ✓ verifyAccessToken makes a single request on success"); + } finally { + stub.close(); + } + }); + + test("verifyAccessToken does not retry on non-401 errors", async () => { + let requestCount = 0; + const { createServer } = await import("node:http"); + const stub = createServer((_req, res) => { + requestCount++; + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "service_unavailable" })); + }); + + await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve)); + const addr = stub.address() as { port: number }; + const baseUrl = `http://127.0.0.1:${addr.port}`; + + try { + const { createGitLabOAuthProvider } = await import("../oauth-proxy.js"); + const provider = createGitLabOAuthProvider(baseUrl, "test-app-id"); + + await assert.rejects( + () => provider.verifyAccessToken("tok"), + /invalid or expired/i, + "non-401 errors still reject" + ); + assert.strictEqual(requestCount, 1, "the query-param fallback is 401-only"); + console.log(" ✓ verifyAccessToken does not retry on non-401 errors"); + } finally { + stub.close(); + } + }); + test("verifyAccessToken maps GitLab token info to AuthInfo", async () => { const createdAt = Math.floor(Date.now() / 1000); const { createServer } = await import("node:http"); From ca9caa987ffcddfca936e996288a08bd6d6c85dd Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 7 Aug 2026 08:42:42 +0200 Subject: [PATCH 3/3] Assert exact Bearer header value and header-free retry in fallback test --- test/mcp-oauth-tests.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/mcp-oauth-tests.ts b/test/mcp-oauth-tests.ts index 101d6fac..19ce7dce 100644 --- a/test/mcp-oauth-tests.ts +++ b/test/mcp-oauth-tests.ts @@ -512,11 +512,11 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => { // header on /oauth/* (e.g. git.drupalcode.org behind Varnish): the // Bearer-header request 401s, the RFC 6750 query-param retry succeeds. const TOKEN = "tok+en/with?special=chars&more"; // exercises URL encoding - const requests: { url: string; hasAuthHeader: boolean }[] = []; + const requests: { url: string; authHeader: string | undefined }[] = []; const { createServer } = await import("node:http"); const stub = createServer((req, res) => { - requests.push({ url: req.url!, hasAuthHeader: "authorization" in req.headers }); + requests.push({ url: req.url!, authHeader: req.headers["authorization"] }); const url = new URL(req.url!, "http://localhost"); if (url.searchParams.get("access_token") === TOKEN) { res.writeHead(200, { "Content-Type": "application/json" }); @@ -546,11 +546,20 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => { const authInfo = await provider.verifyAccessToken(TOKEN); assert.strictEqual(requests.length, 2, "exactly one retry after the 401"); - assert.ok(requests[0].hasAuthHeader, "first attempt uses the Authorization header"); + assert.strictEqual( + requests[0].authHeader, + `Bearer ${TOKEN}`, + "first attempt sends the token as a Bearer Authorization header" + ); assert.ok( !requests[0].url.includes("access_token="), "first attempt does not use the query param" ); + assert.strictEqual( + requests[1].authHeader, + undefined, + "retry omits the Authorization header" + ); assert.ok( requests[1].url.includes(`access_token=${encodeURIComponent(TOKEN)}`), "retry carries the URL-encoded access_token query param"