Skip to content

MCP OAuth login fails with "Authorization server response missing required issuer" although the callback includes a matching iss (RFC 9207) #5

Description

@e2goon

Summary

devin mcp login (and the automatic login triggered by devin mcp add) fails for HTTP MCP servers whose authorization server advertises authorization_response_iss_parameter_supported: true in its RFC 8414 metadata.

The authorization server does include a correct iss parameter in the redirect back to http://127.0.0.1:8765/auth/callback (RFC 9207), but Devin CLI reports:

Failed to exchange authorization code for tokens: Authorization server response missing required issuer: expected http://127.0.0.1:4477

The token endpoint is never called. The same server works as soon as the metadata field is omitted, so the callback parsing seems to drop or never read the iss query parameter before the validation runs.

This matches the regression reported for other rmcp-based clients: openai/codex#32472 (closed as duplicate of openai/codex#31573) and kirodotdev/Kiro#10461.

Environment

  • Devin CLI 3000.6.19 (e2b252e2), also the copy bundled in Devin Desktop 3.9.19 (MCP <server>.log shows the same error)
  • macOS 26.6.2, arm64
  • Node.js 24 for the reproduction server below

Reproduction

A minimal OAuth + MCP server that behaves like a spec-compliant authorization server. It supports dynamic client registration, PKCE, redirects immediately with code, state and iss, and toggles the metadata flag with ADVERTISE_ISS.

server.mjs:

import http from "node:http";
import crypto from "node:crypto";

const PORT = 4477;
const ISSUER = `http://127.0.0.1:${PORT}`;
const ADVERTISE_ISS = process.env.ADVERTISE_ISS !== "0";

const json = (res, status, body, headers = {}) => {
  res.writeHead(status, { "content-type": "application/json", ...headers });
  res.end(JSON.stringify(body));
};
const readBody = (req) =>
  new Promise((resolve) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => resolve(data));
  });

http
  .createServer(async (req, res) => {
    const url = new URL(req.url, ISSUER);
    const body = await readBody(req);
    console.log(req.method, url.pathname + url.search, body);
    switch (url.pathname) {
      case "/mcp": {
        if (!req.headers.authorization) {
          return json(res, 401, { jsonrpc: "2.0", id: null, error: { code: -32000, message: "unauthorized" } }, {
            "www-authenticate": `Bearer resource_metadata="${ISSUER}/.well-known/oauth-protected-resource/mcp", scope="read write"`,
          });
        }
        const id = JSON.parse(body || "{}").id ?? null;
        if (id === null) return res.writeHead(202).end();
        return json(res, 200, { jsonrpc: "2.0", id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "repro", version: "0" } } });
      }
      case "/.well-known/oauth-protected-resource/mcp":
        return json(res, 200, { resource: `${ISSUER}/mcp`, authorization_servers: [ISSUER], scopes_supported: ["read", "write"] });
      case "/.well-known/oauth-authorization-server":
        return json(res, 200, {
          issuer: ISSUER,
          authorization_endpoint: `${ISSUER}/authorize`,
          token_endpoint: `${ISSUER}/token`,
          registration_endpoint: `${ISSUER}/register`,
          response_types_supported: ["code"],
          grant_types_supported: ["authorization_code", "refresh_token"],
          token_endpoint_auth_methods_supported: ["none"],
          code_challenge_methods_supported: ["S256"],
          scopes_supported: ["read", "write"],
          ...(ADVERTISE_ISS ? { authorization_response_iss_parameter_supported: true } : {}),
        });
      case "/register": {
        const { redirect_uris = [] } = JSON.parse(body || "{}");
        return json(res, 201, { client_id: "repro-client", token_endpoint_auth_method: "none", redirect_uris });
      }
      case "/authorize": {
        const redirect = new URL(url.searchParams.get("redirect_uri"));
        redirect.searchParams.set("code", crypto.randomUUID());
        redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
        redirect.searchParams.set("iss", ISSUER);
        console.log("302 ->", redirect.href);
        res.writeHead(302, { location: redirect.href });
        return res.end();
      }
      case "/token":
        return json(res, 200, { access_token: "repro-access-token", token_type: "Bearer", expires_in: 3600, refresh_token: "repro-refresh-token", scope: "read write" });
      default:
        return json(res, 404, { error: "not_found" });
    }
  })
  .listen(PORT, "127.0.0.1", () => console.log("listening on", ISSUER, "advertise iss:", ADVERTISE_ISS));

Case A: flag advertised (fails)

ADVERTISE_ISS=1 node server.mjs &
devin mcp add --scope local repro http://127.0.0.1:4477/mcp   # login starts automatically

CLI output:

Authorize 'repro' by opening this URL in your browser:
http://127.0.0.1:4477/authorize?response_type=code&client_id=repro-client&state=...&code_challenge=...&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fauth%2Fcallback&scope=read+write&resource=http%3A%2F%2F127.0.0.1%3A4477%2Fmcp

Waiting for authorization...

Warning: OAuth login failed: Failed to exchange authorization code for tokens: Authorization server response missing required issuer: expected http://127.0.0.1:4477

Server log. Note the iss parameter in the redirect and that /token is never requested:

GET /mcp
GET /.well-known/oauth-protected-resource/mcp
GET /.well-known/oauth-authorization-server
POST /register {"client_name":"Devin CLI","redirect_uris":["http://127.0.0.1:8765/auth/callback"],...}
GET /authorize?response_type=code&client_id=repro-client&state=CVxzl5AFYcKag7Ky0VQ9CQ&...
302 -> http://127.0.0.1:8765/auth/callback?code=19181a3e-...&state=CVxzl5AFYcKag7Ky0VQ9CQ&iss=http%3A%2F%2F127.0.0.1%3A4477

Case B: flag omitted (works)

ADVERTISE_ISS=0 node server.mjs &
devin mcp login repro
Successfully authenticated with 'repro'!

Server log now shows the token exchange:

302 -> http://127.0.0.1:8765/auth/callback?code=964725e8-...&state=Yql64_iK2KYyPQnqRRns4A&iss=http%3A%2F%2F127.0.0.1%3A4477
POST /token grant_type=authorization_code&code=964725e8-...&code_verifier=...&client_id=repro-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fauth%2Fcallback&resource=http%3A%2F%2F127.0.0.1%3A4477%2Fmcp

Expected

When the metadata advertises authorization_response_iss_parameter_supported, the iss value from the callback query string should be compared with the metadata issuer and, since they match, the code exchange should proceed.

Impact

Authorization servers that set this flag by default (for example Better Auth's OAuth provider and Keycloak) cannot be used with Devin at all. devin mcp login has no option to relax issuer validation, so the only workaround today is for the server operator to strip a correct field from their discovery document.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions