API: an unknown access token gets a 401 without invalid_token, unlike an expired or revoked one

Summary

Follow-up to !261813 (merged), raised in its review.

A request with an access token that GitLab does not know gets a 401 with no error, while an expired or revoked token gets error="invalid_token". RFC 6750 section 3 says that when a request included an access token and failed authentication, the resource server SHOULD include the error attribute, and section 3.1 defines invalid_token for a token that is "expired, revoked, malformed, or invalid for other reasons".

The two paths differ because an unknown token never reaches the API guard's error handler:

  1. The auth finders raise Gitlab::Auth::UnauthorizedError when the token does not exist (find_personal_access_token, and find_oauth_access_token, whose error resolve_access_token rescues before it tries a personal access token).
  2. API::Helpers#initial_current_user rescues UnauthorizedError and calls unauthorized!, which renders {"message":"401 Unauthorized"}.
  3. The guard's handler, which answers expired and revoked tokens, is installed for a list of errors that does not include UnauthorizedError. The list does include Gitlab::Auth::TokenNotFoundError, which the handler maps to invalid_token with "Bad Access Token.", but nothing in the application raises it. Only spec/requests/api/api_guard_spec.rb does, to test the mapping.

On the two MCP endpoints, /api/v4/mcp and /api/v4/orbit/mcp, the unauthorized! override in API::Helpers::Mcp::AuthChallenge adds the endpoint's challenge, still without error. !261813 (merged) gives expired and revoked tokens that same challenge with error="invalid_token" and an error_description, and leaves the unknown-token case as it was, so after it the two challenges there differ only by those two parameters.

Steps to reproduce

  1. Send a request with a bearer token that does not exist to a regular API endpoint and to an MCP endpoint:

    curl --include --header "Authorization: Bearer glpat-notarealtokenatall0000" \
      "https://gitlab.example.com/api/v4/user"
    
    curl --include --request POST --header "Authorization: Bearer glpat-notarealtokenatall0000" \
      --header "Content-Type: application/json" \
      --data '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-06-18"}}' \
      "https://gitlab.example.com/api/v4/mcp"
  2. Send the same requests with a revoked personal access token.

Example Project

Not needed: the responses do not depend on any project.

What is the current bug behavior?

Request Unknown token Revoked token
GET /api/v4/user 401, no WWW-Authenticate, {"message":"401 Unauthorized"} 401, Bearer realm="Protected by OAuth 2.0", error="invalid_token", error_description="Token was revoked. You have to re-authorize from the user."
POST /api/v4/mcp (with !261813 (merged)) 401, Bearer realm="GitLab", scope="mcp", resource_metadata="...", {"message":"401 Unauthorized"} 401, Bearer realm="GitLab", error="invalid_token", error_description="Token was revoked. You have to re-authorize from the user.", scope="mcp", resource_metadata="..."

/api/v4/orbit/mcp answers like /api/v4/mcp, with mcp_orbit and its own metadata URL.

What is the expected correct behavior?

A request that carried an access token GitLab does not know gets error="invalid_token", in the challenge and in the body, as an expired or revoked token does. A request that carried no token keeps the challenge without error, as RFC 6750 section 3.1 asks.

Relevant logs and/or screenshots

Responses from a throwaway request spec on !261813 (merged)'s branch
POST /api/v4/mcp, unknown bearer token
  401
  WWW-Authenticate: Bearer realm="GitLab", scope="mcp", resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/v4/mcp"
  {"message":"401 Unauthorized"}

POST /api/v4/mcp, revoked personal access token
  401
  WWW-Authenticate: Bearer realm="GitLab", error="invalid_token", error_description="Token was revoked. You have to re-authorize from the user.", scope="mcp", resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/v4/mcp"
  {"error":"invalid_token","error_description":"Token was revoked. You have to re-authorize from the user."}

POST /api/v4/orbit/mcp, unknown bearer token
  401
  WWW-Authenticate: Bearer realm="GitLab", scope="mcp_orbit", resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/v4/orbit/mcp"
  {"message":"401 Unauthorized"}

GET /api/v4/user, unknown bearer token
  401
  (no WWW-Authenticate)
  {"message":"401 Unauthorized"}

GET /api/v4/user, revoked personal access token
  401
  WWW-Authenticate: Bearer realm="Protected by OAuth 2.0", error="invalid_token", error_description="Token was revoked. You have to re-authorize from the user."
  {"error":"invalid_token","error_description":"Token was revoked. You have to re-authorize from the user."}

On both MCP endpoints, an unknown token without the glpat- prefix got the same answer as the one with it.

Output of checks

Results of GitLab environment info

The code is cited from master at 6850834e3e1. The responses come from a throwaway request spec on !261813 (merged)'s branch at de2c5124537 (master at f235ea85be5 plus that MR).

Possible fixes

I propose the first now, since it changes nothing outside the two MCP endpoints, and the second as a separate decision.

  • On the MCP endpoints only: have AuthChallenge#unauthorized! add error="invalid_token" and an error_description when the request carried an access token. The finders record the presented token in current_token before the lookup that fails, so the override can tell an unknown token from no token at all. The body would need the same error to match what an expired token gets.
  • API-wide: raise TokenNotFoundError instead of UnauthorizedError when a presented personal access token or OAuth token does not exist, so the guard's existing branch answers it. That changes the 401 body the API endpoints that use the default token lookup return for an unknown token, from {"message":"401 Unauthorized"} to {"error":"invalid_token","error_description":"Bad Access Token."} with a WWW-Authenticate header, and resolve_access_token's fallback from an OAuth lookup to a personal access token rescues UnauthorizedError today, so it would need to handle the new error too. It is a broader change and may want a feature flag.
Edited by 🤖 GitLab Bot 🤖