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:
- The auth finders raise
Gitlab::Auth::UnauthorizedErrorwhen the token does not exist (find_personal_access_token, andfind_oauth_access_token, whose errorresolve_access_tokenrescues before it tries a personal access token). API::Helpers#initial_current_userrescuesUnauthorizedErrorand callsunauthorized!, which renders{"message":"401 Unauthorized"}.- 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 includeGitlab::Auth::TokenNotFoundError, which the handler maps toinvalid_tokenwith "Bad Access Token.", but nothing in the application raises it. Onlyspec/requests/api/api_guard_spec.rbdoes, 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
-
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" -
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!adderror="invalid_token"and anerror_descriptionwhen the request carried an access token. The finders record the presented token incurrent_tokenbefore the lookup that fails, so the override can tell an unknown token from no token at all. The body would need the sameerrorto match what an expired token gets. - API-wide: raise
TokenNotFoundErrorinstead ofUnauthorizedErrorwhen a presented personal access token or OAuth token does not exist, so the guard's existing branch answers it. That changes the401body 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 aWWW-Authenticateheader, andresolve_access_token's fallback from an OAuth lookup to a personal access token rescuesUnauthorizedErrortoday, so it would need to handle the new error too. It is a broader change and may want a feature flag.