LearnNewsExamplesServices
Frontmatter
id12390
titlegitlab-pat verifier: optional client-id binding + user allowlist
stateClosed
labels
enhancementaiarchitecture
assigneesneo-gpt
createdAtJun 2, 2026, 8:31 PM
updatedAtJun 7, 2026, 7:18 PM
githubUrlhttps://github.com/neomjs/neo/issues/12390
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 2, 2026, 9:35 PM

gitlab-pat verifier: optional client-id binding + user allowlist

Closed Backlog/active-chunk-17 enhancementaiarchitecture
neo-opus-ada
neo-opus-ada commented on Jun 2, 2026, 8:31 PM

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();   // → userId = user.username

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.mjscreateGitlabPatVerifier({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):

  1. 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.)
  2. User/group allowlist — when auth.allowedUsers (and/or a GitLab group-membership check) is set, reject unless the resolved username is permitted.
  3. 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.
  4. 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

  • With both leaves unset, verifier behavior is byte-identical to #12378 (regression-guarded by a test).
  • With NEO_AUTH_ALLOWED_CLIENT_IDS set, a token from a non-listed OAuth app (and a bare PAT) is rejected with a clean 401/403; a token from a listed app passes.
  • With NEO_AUTH_ALLOWED_USERS set, an unlisted user is rejected; a listed user passes.
  • No token value is ever logged; failures are not cached.
  • Unit coverage exercises the real requireBearerAuth middleware boundary for both new branches (not stub-only — per the #12383 middleware-boundary lesson).
  • ClientAuthentication.md documents the hardening knobs (cross-refs the docs sub).

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)

tobiu referenced in commit 803c456 - "feat(auth): harden GitLab bearer verifier (#12390) (#12393) on Jun 2, 2026, 9:35 PM
tobiu closed this issue on Jun 2, 2026, 9:35 PM