Class McpServerOAuthAccessTokenAudienceProvider

java.lang.Object
io.meeds.mcp.server.plugin.McpServerOAuthAccessTokenAudienceProvider
All Implemented Interfaces:
io.meeds.oauth2.server.configuration.plugin.OAuthAccessTokenAudienceProvider

@Component public class McpServerOAuthAccessTokenAudienceProvider extends Object implements io.meeds.oauth2.server.configuration.plugin.OAuthAccessTokenAudienceProvider
Names this MCP server as the audience of the access tokens issued for its tool scopes — and refuses the token outright for a user the MCP audience excludes.

That refusal is what keeps an excluded user from adding the server to an external MCP client at all. Without it they complete the whole OAuth flow — discovery, dynamic client registration, login, consent — obtain a token, and only then meet the door's 401: they end up owning a connector that looks broken. With it the token request fails with access_denied, and nothing is ever recorded as connected. A refresh goes through the same customizer, so an audience narrowed later bites at the next refresh as well as, through the door, at the very next request.

The refusal has to be raised, not expressed by returning null. OAuthAccessTokenCustomizerService.computeJwtAudiences takes the first non-empty answer among the registered providers and only fails the request when every one of them abstains — and the authorization server ships a second provider, OAuthAccessTokenAudienceTokenRequestProvider, which answers with the resource parameter of the authorization request (RFC 8707) whenever that value is one of OAuthSettingService.getAllowedAudiences(). This addon registers its own URL as exactly such an allowed audience, and MCP clients do send resource. So a null here would merely abstain, that provider would name the MCP URL in its place, and the excluded user would get a token anyway. Throwing OAuth2AuthenticationException instead ends the stream before any other provider is consulted, and the customizer propagates it as the token endpoint's error response.

The refusal depends on this provider being consulted first. computeJwtAudiences is a short-circuiting stream, so the throw below happens only while this provider is reached before OAuthAccessTokenAudienceTokenRequestProvider — which would otherwise answer from resource and end the stream. That ordering is an accident of OAuthAccessTokenCustomizerService's comparator, (p1, p2) -> p2.getOrder() - p1.getOrder(): this provider keeps the default HIGHEST_PRECEDENCE (Integer.MIN_VALUE), the resource provider declares LOWEST_PRECEDENCE (Integer.MAX_VALUE), and MAX_VALUE - MIN_VALUE overflows to -1 — so this provider sorts first because the subtraction overflows. Computed without overflow, that descending sort would put the resource provider first and the refusal below would never be reached; of the two overflow-free rewrites, only an ascending Integer.compare(p1.getOrder(), p2.getOrder()) — the Spring Ordered convention the two constants' names imply — preserves the gate. Making the refusal independent of position is the authorization server's to do (consult every provider before choosing, or an explicit veto in the SPI); until then the aggregate test, which registers both providers through the real addProvider, is the tripwire: if it goes red after a change to that comparator, the MCP token gate has stopped working and the test is right.

  • Constructor Details

    • McpServerOAuthAccessTokenAudienceProvider

      public McpServerOAuthAccessTokenAudienceProvider()
  • Method Details

    • init

      @PostConstruct public void init()
      Registers this provider with the authorization server's access token customizer, which consults it on every access token it issues.
    • provideAudiences

      public List<String> provideAudiences(org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext context)
      Specified by:
      provideAudiences in interface io.meeds.oauth2.server.configuration.plugin.OAuthAccessTokenAudienceProvider
      Parameters:
      context - the access token being issued
      Returns:
      this MCP server's URL as the token's audience when the token carries an MCP tool scope and either has no end user (a client-credentials grant) or has one the MCP server is enabled for; null for a token without an MCP scope, which lets another provider answer
      Throws:
      org.springframework.security.oauth2.core.OAuth2AuthenticationException - with access_denied for an MCP token whose end user is outside the MCP audience, or whose context carries no principal at all — a grant this code does not know fails closed, as a refusal rather than an abstention another provider could override