Context
The GitLab-PAT bearer auth mode shipped in #12378 (epic #12377) authenticates a request by presenting the incoming bearer to GET {gitlabApiBaseUrl}/api/v4/user; any 200 resolves a GitLab user and the request is accepted. This is authentication, not authorization: any valid account on the configured GitLab instance (with read_user) is accepted, and there is no binding that the token was minted for this MCP server.
This surfaced while settling the recommended client-auth posture for a GitLab-backed cloud deployment: the OAuth-browser-login path (a client obtains a short-lived GitLab OAuth access token from a pre-registered OAuth app and sends it as the bearer) is the enterprise-appropriate primary path, and it works today because the verifier is token-type-agnostic. But an enterprise security review correctly flags that the server accepts any GitLab user's token, not just one issued for the registered MCP app, and enforces no per-user/-group authorization.
The Problem
AuthService.createGitlabPatVerifier validates a bearer purely by resolving an identity:
const response = await fetch(`${apiBaseUrl}/api/v4/user`, { headers: {Authorization: `Bearer ${token}`} });
if (!response.ok) throw new InvalidTokenError(...);
const user = await response.json();
Consequences:
- No audience binding. GitLab does not implement RFC 8707 resource indicators, so it cannot mint a token whose
aud is this MCP server. /api/v4/user validation therefore cannot verify "issued for this resource." The MCP spec permits this "when the Authorization Server supports the capability" — i.e. it is an acknowledged limitation, not a violation — but it leaves a confused-deputy / token-reuse surface for a hard enterprise bar.
- No authorization. There is no per-user or per-group allowlist; membership on the GitLab instance == access.
The Architectural Reality
ai/mcp/server/shared/services/AuthService.mjs → createGitlabPatVerifier({aiConfig, logger, InvalidTokenError}) (the /api/v4/user fetch + buildInfo). This is the single chokepoint where a verified branch slots in.
- The OAuth-app binding is feasible because GitLab's
GET /oauth/token/info returns the issuing app's application.uid (the OAuth client_id) and scope for an OAuth access token. A raw PAT has no owning application, so the check naturally distinguishes OAuth-app tokens from bare PATs. (Exact field shape to be confirmed against the deployment's GitLab version at implementation — Surface-Anchor V-B-A.)
- Config leaves live in the realm-root
ai/config.template.mjs auth block (Tier-1), inherited by the per-server templates via the getParent() realm chain.
The Fix
Add an optional, env-gated, default-OFF verification branch to createGitlabPatVerifier (default-off preserves the current shipped behavior exactly):
- Client-id / app binding — when
auth.allowedClientIds is set, additionally call GET {gitlabApiBaseUrl}/oauth/token/info and reject (InvalidTokenError) unless the token's application.uid is in the allowlist. (Rejects bare PATs + tokens from other apps.)
- User/group allowlist — when
auth.allowedUsers (and/or a GitLab group-membership check) is set, reject unless the resolved username is permitted.
- New Tier-1 config leaves:
auth.allowedClientIds (env NEO_AUTH_ALLOWED_CLIENT_IDS, csv→array), auth.allowedUsers (env NEO_AUTH_ALLOWED_USERS, csv→array). Empty/unset = current behavior.
- Cache + no-token-logging discipline unchanged; failures never cached.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
auth.allowedClientIds leaf (NEO_AUTH_ALLOWED_CLIENT_IDS) |
this ticket |
unset → no binding (current behavior); set → only tokens whose GitLab application.uid is listed pass |
unset = accept any valid GitLab token (today) |
ClientAuthentication.md + config JSDoc |
unit test: allowed app passes, other app + bare PAT rejected |
auth.allowedUsers leaf (NEO_AUTH_ALLOWED_USERS) |
this ticket |
unset → no allowlist; set → only listed usernames pass |
unset = any valid GitLab user (today) |
config JSDoc |
unit test: listed user passes, unlisted rejected |
createGitlabPatVerifier behavior |
AuthService.mjs (existing) |
adds optional /oauth/token/info call + allowlist gate; default path unchanged |
default-off identical to #12378 |
code JSDoc |
middleware-boundary test on both branches |
GET /oauth/token/info (GitLab) |
GitLab OAuth API |
read application.uid + scope to bind client + scope |
absent app (PAT) → fail closed only when allowlist set |
n/a (external) |
live-probe field shape at impl (Surface-Anchor V-B-A) |
Decision Record impact
none — aligns with the existing gitlab-pat auth mode (#12378); adds an optional hardening branch. Does not challenge or supersede an ADR. (ADR 0014 covers cloud-deployment topology, not the verifier's authZ policy.)
Acceptance Criteria
Out of Scope
- Making the checks default-ON (opt-in only; preserves zero-config first-deployment ergonomics).
- Implementing RFC 8707 audience-bound tokens (GitLab cannot issue them; out of neo's control).
- Replacing the
/api/v4/user identity-resolution path; this augments, not replaces it.
- The OIDC/Keycloak server-mode authZ (separate substrate).
Avoided Traps
- Default-on rejected. Forcing the allowlist on by default would break the deliberately-zero-config first-deployment path #12378 ships. Hardening is opt-in for deployments with a hard enterprise bar.
- Treating
/api/v4/user as sufficient for authZ rejected. It only proves "a valid instance user," which is the exact gap this closes.
Related
- Epic #12377 (GitLab-PAT bearer auth) · foundation #12378 · docs #12380 / #12379.
- Docs sibling: the
ClientAuthentication.md OAuth-browser-login doc ticket (filed alongside this).
Origin Session ID: c279991f-11eb-4e89-9e38-2c9c4a78421e
Handoff Retrieval Hints: query_raw_memories("gitlab-pat verifier client_id scope allowlist audience /api/v4/user /oauth/token/info"); anchor file ai/mcp/server/shared/services/AuthService.mjs createGitlabPatVerifier.
Authored by @neo-opus-ada (claude-opus-4.8-1m)
Context
The GitLab-PAT bearer auth mode shipped in #12378 (epic #12377) authenticates a request by presenting the incoming bearer to
GET {gitlabApiBaseUrl}/api/v4/user; any200resolves a GitLab user and the request is accepted. This is authentication, not authorization: any valid account on the configured GitLab instance (withread_user) is accepted, and there is no binding that the token was minted for this MCP server.This surfaced while settling the recommended client-auth posture for a GitLab-backed cloud deployment: the OAuth-browser-login path (a client obtains a short-lived GitLab OAuth access token from a pre-registered OAuth app and sends it as the bearer) is the enterprise-appropriate primary path, and it works today because the verifier is token-type-agnostic. But an enterprise security review correctly flags that the server accepts any GitLab user's token, not just one issued for the registered MCP app, and enforces no per-user/-group authorization.
The Problem
AuthService.createGitlabPatVerifiervalidates a bearer purely by resolving an identity:const response = await fetch(`${apiBaseUrl}/api/v4/user`, { headers: {Authorization: `Bearer ${token}`} }); if (!response.ok) throw new InvalidTokenError(...); const user = await response.json(); // → userId = user.usernameConsequences:
audis this MCP server./api/v4/uservalidation therefore cannot verify "issued for this resource." The MCP spec permits this "when the Authorization Server supports the capability" — i.e. it is an acknowledged limitation, not a violation — but it leaves a confused-deputy / token-reuse surface for a hard enterprise bar.The Architectural Reality
ai/mcp/server/shared/services/AuthService.mjs→createGitlabPatVerifier({aiConfig, logger, InvalidTokenError})(the/api/v4/userfetch +buildInfo). This is the single chokepoint where a verified branch slots in.GET /oauth/token/inforeturns the issuing app'sapplication.uid(the OAuthclient_id) andscopefor an OAuth access token. A raw PAT has no owning application, so the check naturally distinguishes OAuth-app tokens from bare PATs. (Exact field shape to be confirmed against the deployment's GitLab version at implementation — Surface-Anchor V-B-A.)ai/config.template.mjsauthblock (Tier-1), inherited by the per-server templates via thegetParent()realm chain.The Fix
Add an optional, env-gated, default-OFF verification branch to
createGitlabPatVerifier(default-off preserves the current shipped behavior exactly):auth.allowedClientIdsis set, additionally callGET {gitlabApiBaseUrl}/oauth/token/infoand reject (InvalidTokenError) unless the token'sapplication.uidis in the allowlist. (Rejects bare PATs + tokens from other apps.)auth.allowedUsers(and/or a GitLab group-membership check) is set, reject unless the resolvedusernameis permitted.auth.allowedClientIds(envNEO_AUTH_ALLOWED_CLIENT_IDS, csv→array),auth.allowedUsers(envNEO_AUTH_ALLOWED_USERS, csv→array). Empty/unset = current behavior.Contract Ledger Matrix
auth.allowedClientIdsleaf (NEO_AUTH_ALLOWED_CLIENT_IDS)application.uidis listed passClientAuthentication.md+ config JSDocauth.allowedUsersleaf (NEO_AUTH_ALLOWED_USERS)createGitlabPatVerifierbehaviorAuthService.mjs(existing)/oauth/token/infocall + allowlist gate; default path unchangedGET /oauth/token/info(GitLab)application.uid+scopeto bind client + scopeDecision Record impact
none— aligns with the existinggitlab-patauth mode (#12378); adds an optional hardening branch. Does not challenge or supersede an ADR. (ADR 0014 covers cloud-deployment topology, not the verifier's authZ policy.)Acceptance Criteria
NEO_AUTH_ALLOWED_CLIENT_IDSset, a token from a non-listed OAuth app (and a bare PAT) is rejected with a clean401/403; a token from a listed app passes.NEO_AUTH_ALLOWED_USERSset, an unlisted user is rejected; a listed user passes.requireBearerAuthmiddleware boundary for both new branches (not stub-only — per the #12383 middleware-boundary lesson).ClientAuthentication.mddocuments the hardening knobs (cross-refs the docs sub).Out of Scope
/api/v4/useridentity-resolution path; this augments, not replaces it.Avoided Traps
/api/v4/useras sufficient for authZ rejected. It only proves "a valid instance user," which is the exact gap this closes.Related
ClientAuthentication.mdOAuth-browser-login doc ticket (filed alongside this).Origin Session ID: c279991f-11eb-4e89-9e38-2c9c4a78421e
Handoff Retrieval Hints:
query_raw_memories("gitlab-pat verifier client_id scope allowlist audience /api/v4/user /oauth/token/info"); anchor fileai/mcp/server/shared/services/AuthService.mjscreateGitlabPatVerifier.Authored by @neo-opus-ada (claude-opus-4.8-1m)