Skip to content

Conversation

chipgpt
Copy link

@chipgpt chipgpt commented Sep 30, 2025

This updates the auth flow to prefer the scope param from the WWW-Authenticate header if it exists.

Fixes #978

Motivation and Context

This is motivated by SEP-835. The current implementation defaults to the scopes_supported from the metadata file and ignores the scope param from the WWW-Authenticate header. SEP-835 says the scope provided by the WWW-Authenticate header should be preferred.

How Has This Been Tested?

Local tests have been updated and are passing. This has not been tested in a real application.

Breaking Changes

No breaking changes.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@chipgpt chipgpt requested review from a team, ochafik and felixweinberger September 30, 2025 02:26
@chipgpt chipgpt mentioned this pull request Sep 30, 2025
Comment on lines 148 to 152
if (response.status === 401 && response.headers.has('www-authenticate')) {
this._resourceMetadataUrl = extractResourceMetadataUrl(response);
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this section of code only caches header params if the header exists (&& response.headers.has('www-authenticate')). If the header does not exist, it would still use the same values cached from the previous www-authenticate header.

Would it make more sense for it to clear the cached values if a new 401 is received without the header? It would mean simply removing the logic that checks for the header and instead let the extractWWWAuthenticateParams handle it:

if (response.status === 401) {
  const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
  this._resourceMetadataUrl = resourceMetadataUrl;
  this._scope = scope;
}

In this case, if the header does not exist, it will reset both cached values to undefined and fall back to the oauth metadata values.

@TylerLeonhardt
Copy link
Contributor

I think there's also some server side work to be done here as well, as I am seeing this line of code conflict with the concept of step up:

// Check each requested scope against allowed scopes
for (const scope of requestedScopes) {
if (!allowedScopes.has(scope)) {
throw new InvalidScopeError(`Client was not registered with scope ${scope}`);
}
}

@chipgpt
Copy link
Author

chipgpt commented Oct 6, 2025

I think there's also some server side work to be done here as well, as I am seeing this line of code conflict with the concept of step up:

// Check each requested scope against allowed scopes
for (const scope of requestedScopes) {
if (!allowedScopes.has(scope)) {
throw new InvalidScopeError(`Client was not registered with scope ${scope}`);
}
}

I was thinking the step up concept is for access tokens, not clients. So if a client is limited to a certain set of scopes, it is still limited to them.

I think this is for the case where I have a client that has read and write scope available and I have an access token that only has read scope, then I can step up to an access token that includes write.

@TylerLeonhardt
Copy link
Contributor

The DCR spec does not mention that a client can't ask for more.

scope
      String containing a space-separated list of scope values (as
      described in [Section 3.3](https://datatracker.ietf.org/doc/html/rfc7591#section-3.3) of OAuth 2.0 [[RFC6749](https://datatracker.ietf.org/doc/html/rfc6749)]) that the client
      can use when requesting access tokens.  The semantics of values in
      this list are service specific.  If omitted, an authorization
      server MAY register a client with a default set of scopes.

It says that it can use but it doesn't say it can't use any alternative value. As such, I think we remove this check from authorize.ts.

Also, your example makes sense until the MCP Server introduces new functionality that increases the list of all scopes to read write foo... there is nothing in the spec that dictates a clear error of "you've asked for more than your registration says" with the authorization server. Because of this reason as well, I think it should be interpreted as there's no hard limit.

@chipgpt
Copy link
Author

chipgpt commented Oct 6, 2025

The DCR spec does not mention that a client can't ask for more.

scope
      String containing a space-separated list of scope values (as
      described in [Section 3.3](https://datatracker.ietf.org/doc/html/rfc7591#section-3.3) of OAuth 2.0 [[RFC6749](https://datatracker.ietf.org/doc/html/rfc6749)]) that the client
      can use when requesting access tokens.  The semantics of values in
      this list are service specific.  If omitted, an authorization
      server MAY register a client with a default set of scopes.

It says that it can use but it doesn't say it can't use any alternative value. As such, I think we remove this check from authorize.ts.

Also, your example makes sense until the MCP Server introduces new functionality that increases the list of all scopes to read write foo... there is nothing in the spec that dictates a clear error of "you've asked for more than your registration says" with the authorization server. Because of this reason as well, I think it should be interpreted as there's no hard limit.

If that is the intended behavior then it seems like that block of code should be removed as there is no such thing as "allowed scopes". I can't speak for why it was included to begin with though.

@TylerLeonhardt
Copy link
Contributor

I can't speak for why it was included to begin with though.

Yeah, I can't either. Just something @localden and I agreed to on Discord.

@chipgpt chipgpt force-pushed the task/support-www-authenticate-scope-param branch from 0806e81 to e338f1c Compare October 7, 2025 02:04
@chipgpt chipgpt requested review from a team as code owners October 7, 2025 02:04
Comment on lines -123 to -130
const allowedScopes = new Set(client.scope?.split(' '));

// Check each requested scope against allowed scopes
for (const scope of requestedScopes) {
if (!allowedScopes.has(scope)) {
throw new InvalidScopeError(`Client was not registered with scope ${scope}`);
}
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this check in order to support the scope step up flow. Clients should be able to step up to scopes beyond the initial client registration scopes.

Comment on lines -221 to -251
describe('Scope validation', () => {
it('validates requested scopes against client registered scopes', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
scope: 'profile email admin' // 'admin' not in client scopes
});

expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.get('error')).toBe('invalid_scope');
});

it('accepts valid scopes subset', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
scope: 'profile' // subset of client scopes
});

expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.searchParams.has('code')).toBe(true);
});
});
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support SEP-835
3 participants