Skip to content

[v2] Incompatibility with refresh_tokens from Azure when client_id=resource_id #2718

Closed

Description

@lukeyeager

What happened?

I have some MCP servers which require access_tokens from login.microsoftonline.com, and for which the OAuth client ID and the OAuth resource ID are the same Azure app.

All AI agent harnesses can successfully authenticate to these MCP servers initially, but several fail to fetch new access_tokens when they expire using the refresh_token.

This is because some MCP client libraries (including this one) don't handle the scope parameter when using a refresh_token in the way that Azure requires. This leads to the following misleading error message:

$ node repro.mjs "${my_tenantid}" "${my_clientid}" "api://${my_clientid}/access_as_user"
...
InvalidRequestError: AADSTS90009: Application 'REDACTED'(REDACTED) is requesting a token for it
self. This scenario is supported only if resource is specified using the GUID based App Identifier. Trace ID: REDACTED Correlation ID: REDACTED Timestamp: 2026-08-26 01:06:39Z

One way to fix this error actually has nothing to do with whether an app is requesting a token for itself or not - it is to pass along the same scope parameter with the refresh request as was used in the original request.

How's the support for other harnesses?

Other related issues:

What did you expect?

No response

Code to reproduce

import { randomUUID } from "node:crypto";
import http from "node:http";
import { exchangeAuthorization, refreshAuthorization, startAuthorization } from "@modelcontextprotocol/client";

const [tenant, clientId, apiScope] = process.argv.slice(2);
if (!tenant || !clientId || !apiScope) {
  console.error('Usage: node repro.mjs TENANT_ID CLIENT_ID "API_SCOPE"');
  process.exit(2);
}

const root = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0`;
const redirectUri = "http://localhost:53682/callback";
const scope = `openid profile offline_access ${apiScope}`;
const clientInformation = { client_id: clientId };
const metadata = {
  authorization_endpoint: `${root}/authorize`,
  token_endpoint: `${root}/token`,
  response_types_supported: ["code"],
  code_challenge_methods_supported: ["S256"],
};
const state = randomUUID();
const { authorizationUrl, codeVerifier } = await startAuthorization(root, {
  metadata, clientInformation, redirectUrl: redirectUri, scope, state,
});

const authorizationCode = await new Promise((resolve, reject) => {
  const server = http.createServer((request, response) => {
    const url = new URL(request.url, redirectUri);
    if (url.pathname !== "/callback") return response.writeHead(404).end();
    const code = url.searchParams.get("code");
    const error = url.searchParams.get("error");
    const valid = url.searchParams.get("state") === state;
    response.end(valid && code ? "Authorization complete." : "Authorization failed.");
    server.close();
    if (error) reject(new Error(error));
    else if (!valid || !code) reject(new Error("Invalid OAuth callback"));
    else resolve(code);
  });
  server.once("error", reject);
  server.listen(53682, () => console.log(`Open this URL:\n\n${authorizationUrl}\n`));
});

const trace = (label) => async (url, init) => {
  const body = new URLSearchParams(init.body.toString());
  console.log(`${label}:`, { grant_type: body.get("grant_type"), scope: body.get("scope") ?? "<redacted>" });
  return fetch(url, init);
};
const initial = await exchangeAuthorization(root, {
  metadata, clientInformation, authorizationCode, codeVerifier, redirectUri,
  addClientAuthentication: async (_headers, body) => {
    body.set("client_id", clientId);
    body.set("scope", scope);
  },
  fetchFn: trace("initial request"),
});
if (!initial.refresh_token) throw new Error("No refresh token returned");
console.log("Initial access and refresh tokens received.");

const refreshed = await refreshAuthorization(root, {
  metadata, clientInformation, refreshToken: initial.refresh_token,
  fetchFn: trace("refresh request"),
});
console.log("Refreshed access token received:", !!refreshed.access_token);

SDK version

1.29.0 and also 2.0.0

Area

Auth

Activity

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Aug 26, 2026
  2. thegodshatetexas commented on Oct 2, 2026

    @thegodshatetexas

    Independent confirmation from a second Entra tenant, plus two findings that I think change what has to land for this to reach Claude Code users.

    Measured against a live tenant

    Same shape as your repro: one app registration acting as both the OAuth client and the resource, public client, requestedAccessTokenVersion: 2. I ran six refresh grants by hand against a fresh PKCE-obtained refresh token, varying only resource and scope.

    resource scope result
    server URL (what the SDK sends) absent AADSTS90009
    absent absent AADSTS90009
    Microsoft Graph absent AADSTS9010010
    api://<client-id> absent AADSTS90009
    absent granted scope 200
    server URL granted scope 200

    Every failure omits scope. Every success includes it. The three failures differ only in resource and fail identically, so the "GUID based App Identifier" wording in the error is a red herring. A single registration serving as both client and resource refreshes fine, with no resource parameter at all, as long as scope is present.

    The Graph row is the useful one for mechanism. Entra does read resource at /token, because it returned a resource-specific error instead of falling through to 90009. It just cannot resolve a target from resource alone when no scope is present.

    Claude Code has a second refresh path that auth() does not cover

    I read the shipped Claude Code 2.1.287 binary. There are two call sites of refreshAuthorization in it, and neither passes a scope.

    The first is the SDK's own auth(), unchanged from v1.x:

    const tokens = await provider.tokens();
    if (tokens?.refresh_token) {
      const newTokens = await refreshAuthorization(authorizationServerUrl, {
        metadata, clientInformation, refreshToken: tokens.refresh_token,
        resource, addClientAuthentication: provider.addClientAuthentication, fetchFn
      });

    The second is Claude Code's own proactive refresh, which calls refreshAuthorization directly and never goes through auth():

    await refreshAuthorization(new URL(this.serverConfig.url), {
      metadata, clientInformation,
      refreshToken: e,
      resource: new URL(this.serverConfig.url),
      fetchFn: s
    });

    This matters for the shape of the fix. #2720 changes auth() to send tokens.scope, which covers the first path but not the second. The second is the one that fires in practice. Across my local logs there are 196 refresh failures in 30 files, and all 196 were triggered by Token expires in <n>s, attempting proactive refresh running into that direct call. None came through auth().

    So the additive scope option on refreshAuthorization is the part that matters for this client, and Claude Code then has to pass it at its own call site. It already holds the value. The same class captures it at authorize time into this._scopes and logs Captured scopes from authorization URL, and it is also persisted as scope on the stored tokens, which is the copy that survives a session restart. Captured, stored, and then not sent on refresh.

    Claude Code bundles v1.x, and #2720 targets v2

    The bundled refreshAuthorization and executeTokenRequest match src/client/auth.ts at tag 1.31.0 parameter for parameter, including argument order. They do not match current main, which adds a dpop parameter. v1.31.0 mentions dpop 0 times, main 43, the bundled copy 0.

    #2720 says it plainly already: "v1.x (src/client/auth.ts) has the same two-parameter request body; happy to open the backport if wanted."

    Merging #2720 as it stands will not fix any Claude Code user, because Claude Code is not on v2.

    I have opened the v1.x backport as #2937. It mirrors #2720's design deliberately so the two lines stay consistent: optional scope on refreshAuthorization set only when non-empty, granted scope preserved on the result when the response omits it, and auth() sending exactly tokens.scope. It is +116/-2 across three files including a changeset, tsgo --noEmit is clean, eslint and prettier are clean, and the full suite passes at 1857 tests over 56 files. The two behavioral tests were written failing-first and do fail with the source change reverted.

    If #2720's design shifts in review I will rebase #2937 to match rather than let the lines diverge, and I am happy for it to wait behind #2720 if you would rather take them in order.

    On the spec question

    #2720 already frames this correctly and I want to reinforce it, since it is the argument most likely to get this closed by mistake. Omitting scope on a refresh grant is legal under RFC 6749 §6. The fix does not send a widened scope, it sends the scope already granted, which §6 permits and which is what the server would assume anyway. Nothing about this asks the SDK to violate the spec. It asks it to be explicit where Entra cannot infer.

  3. alexchow94 commented on Oct 6, 2026

    @alexchow94

    Another confirmation, from a second Entra tenant, plus a client that isn't listed yet: Cursor.

    Setup: one app registration serves as both the OAuth client and the resource (public client, PKCE,
    requestedAccessTokenVersion: 2). Its Application ID URIs are the MCP server URLs, and the PRM resource
    is the server URL. Initial sign-in works in Cursor. About 75 minutes later, every refresh fails with
    AADSTS90009, and the server stays in an error state until the user signs in again.

    Cursor uses the v1.x code path that #2937 fixes. In Cursor 3.23's bundled MCP client:

    • The token request helper has no DPoP parameter; its shape matches executeTokenRequest in 1.31.0.
    • There is a single refreshAuthorization call site: the SDK's own auth() path. There is no separate
      proactive refresh like the one described above for Claude Code.
    • The OAuth provider doesn't implement addClientAuthentication, so nothing adds scope to the request.

    So the v1.x backport (#2937) should be all Cursor needs, once it's bundled. Cursor already records the
    granted scope from the token response.

    Raw refresh grants against a fresh PKCE refresh token, varying only resource and where scope goes:

    resource scope Result
    Server URL (what the SDK sends) absent AADSTS90009
    Client ID (bare GUID) absent AADSTS90009
    absent absent AADSTS90009
    absent granted scope, in the request body 200
    Server URL granted scope, only as a query parameter on the token endpoint URL AADSTS90009
    absent granted scope, only as a query parameter on the token endpoint URL AADSTS90009

    Two findings I haven't seen in this thread:

    1. The bare client-ID GUID as resource fails too, so the "GUID based App Identifier" hint in the
      error doesn't help on the v2 endpoint.
    2. Entra ignores scope passed in the token endpoint's query string. A server therefore can't work
      around this by advertising a token_endpoint with ?scope=… in its RFC 8414 metadata; the fix has
      to be in the client.

    Happy to test #2937 against our tenant if that helps.

  4. felixweinberger commented on Oct 23, 2026

    @felixweinberger
    Contributor

    #3085 adds an opt-in: implement scopeForRefresh on the provider to send a scope on refresh requests. The default request does not change. It will be in the next release. Closing.


    Generated by Claude Code

  5. lukeyeager commented on Oct 23, 2026

    @lukeyeager
    Author

    Ugh. I had hoped you were being too conservative with your choice not to change the default behavior in #3085 but then I found this issue - google/adk-python#5328. So I guess it'll be up to your clients (e.g. opencode and pi) to decide how they want to address this discrepancy between Azure and Salesforce. My work isn't done. Thanks for adding the flag here, though!

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

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions