T17: GitLab connect (OAuth + read-only PAT) #15

Merged
dzenda merged 62 commits from t17-gitlab-connect into t15-mcp-tools 2026-11-14 17:50:06 +01:00
Owner

T17: GitLab connect (OAuth + refresh, read-only PAT fallback)

Stacked on #14 (t15-mcp-tools) → … → #1. Merge those first.

What

  • src/lib/gitlab.ts:
    • OAuth: authorize with read_api read_repository + PKCE S256; code exchange with HTTP Basic client auth; refresh via gitlabRefresher. GitLab rotates refresh tokens, and the T4 token store handles that safely. invalid_grant maps to RefreshRejectedError, which becomes reauth_required.
    • PAT validation via /personal_access_tokens/self: the token must be active and have read_api, and every scope must be read-only (read_api, read_repository, read_user, read_registry). Tokens with api, write_repository, sudo and similar are refused, so we never store a GitLab token that can write.
    • Everything uses redirect: "manual", and upstream error bodies are never surfaced.
  • src/server/gitlab-connect.ts:
    • GET /auth/gitlab/start: needs the website session. state + PKCE verifier + user id + expiry are sealed with the AES-GCM keyring in a Path=/auth/gitlab; HttpOnly; SameSite=Lax; Max-Age=600 cookie (Secure on https).
    • GET /auth/gitlab/callback (the registered redirect URI): decrypts the cookie and checks expiry, state (constant-time) and that it's the same user as the session, which blocks binding someone else's GitLab account. Then it exchanges the code, binds the GitLab user, saves the encrypted connection, and clears the cookie. Every failure redirects to /connectors?gitlab=error&reason=<code> with no upstream text.
    • POST /api/connections/gitlab/pat (HTML form): session plus a same-origin Origin check, token format check, validation, then encrypted storage with the PAT's expiry. An expired PAT gives reauth_required instead of a silent failure.
  • src/server/services.ts: per-isolate D1, keyring and a token store with both FIT and GitLab refreshers. The GitLab refresher is only registered when OAuth is configured. Now used by MCP as well.
  • /connectors page (minimal, en/cs; T20 builds the full screen): FIT and GitLab status (account, oauth/pat), "Connect GitLab" when OAuth is configured, and the PAT fallback form. The form is a password input linking to GitLab's token page with the name and read-only scopes pre-filled. It also gives the /connectors links in error messages a real page.
  • .dev.vars.example gets an optional GITLAB_BASE_URL for other GitLab instances.

Probed gitlab.fit.cvut.cz

OIDC discovery shows S256 PKCE, client_secret_basic/client_secret_post, and the read_api/read_repository scopes. A bogus refresh returns 400 invalid_grant.

Verified

  • bun run lint && bun run typecheck && bun run test: 209 tests, 17 of them for GitLab.
    • Client: config, authorize URL, no-OAuth refusal, code exchange (Basic auth + PKCE, no redirects), refresh rotation and invalid_grant without leaking, PAT accept/insufficient/write scopes/inactive/unknown/no-expiry.
    • Handlers: login required, not configured; the cookie is encrypted and scoped with no plaintext state; the full connect flow stores an encrypted token whose verifier matches the challenge; state mismatch, missing or forged cookie, expired state and different user are all rejected; denial and exchange failure store nothing; PAT happy path (encrypted, expiry kept); cross-origin POST, bad format and write-scoped or unknown PATs are rejected; session required.
  • bun run dev + curl: anonymous /connectors, /auth/gitlab/start and the PAT POST redirect to /login?next=/connectors; the callback without a session gives ?gitlab=error&reason=signed_out.
  • Not yet run against real GitLab. That needs the GitLab OAuth app (see Needs you) or a PAT pasted into /connectors after the browser login.

Astra review (gpt-6-astra)

"No concrete blocking issues found." It confirmed that state and PKCE are bound to the signed-in user, PATs require same-origin and read-only scopes, storage is encrypted, and redirects use configured destinations.

🤖 Generated with Claude Code

## T17: GitLab connect (OAuth + refresh, read-only PAT fallback) **Stacked on #14 (`t15-mcp-tools`) → … → #1.** Merge those first. ### What - `src/lib/gitlab.ts`: - **OAuth:** authorize with `read_api read_repository` + PKCE S256; code exchange with HTTP Basic client auth; refresh via `gitlabRefresher`. GitLab rotates refresh tokens, and the T4 token store handles that safely. `invalid_grant` maps to `RefreshRejectedError`, which becomes `reauth_required`. - **PAT validation** via `/personal_access_tokens/self`: the token must be active and have `read_api`, and **every scope must be read-only** (`read_api`, `read_repository`, `read_user`, `read_registry`). Tokens with `api`, `write_repository`, `sudo` and similar are refused, so **we never store a GitLab token that can write**. - Everything uses `redirect: "manual"`, and upstream error bodies are never surfaced. - `src/server/gitlab-connect.ts`: - **`GET /auth/gitlab/start`:** needs the website session. `state` + PKCE verifier + user id + expiry are **sealed with the AES-GCM keyring** in a `Path=/auth/gitlab; HttpOnly; SameSite=Lax; Max-Age=600` cookie (`Secure` on https). - **`GET /auth/gitlab/callback`** (the registered redirect URI): decrypts the cookie and checks expiry, `state` (constant-time) and that it's **the same user as the session**, which blocks binding someone else's GitLab account. Then it exchanges the code, binds the GitLab user, saves the encrypted connection, and clears the cookie. Every failure redirects to `/connectors?gitlab=error&reason=<code>` with no upstream text. - **`POST /api/connections/gitlab/pat`** (HTML form): session plus a same-origin `Origin` check, token format check, validation, then encrypted storage with the PAT's expiry. An expired PAT gives `reauth_required` instead of a silent failure. - `src/server/services.ts`: per-isolate D1, keyring and a **token store with both FIT and GitLab refreshers**. The GitLab refresher is only registered when OAuth is configured. Now used by MCP as well. - **`/connectors` page** (minimal, en/cs; T20 builds the full screen): FIT and GitLab status (account, oauth/pat), "Connect GitLab" when OAuth is configured, and the **PAT fallback form**. The form is a password input linking to GitLab's token page with the name and read-only scopes pre-filled. It also gives the `/connectors` links in error messages a real page. - `.dev.vars.example` gets an optional `GITLAB_BASE_URL` for other GitLab instances. ### Probed `gitlab.fit.cvut.cz` OIDC discovery shows S256 PKCE, `client_secret_basic`/`client_secret_post`, and the `read_api`/`read_repository` scopes. A bogus refresh returns `400 invalid_grant`. ### Verified - `bun run lint && bun run typecheck && bun run test`: 209 tests, 17 of them for GitLab. - **Client:** config, authorize URL, no-OAuth refusal, code exchange (Basic auth + PKCE, no redirects), refresh rotation and `invalid_grant` without leaking, PAT accept/insufficient/**write scopes**/inactive/unknown/no-expiry. - **Handlers:** login required, not configured; the cookie is encrypted and scoped with no plaintext state; the full connect flow stores an encrypted token whose verifier matches the challenge; state mismatch, missing or forged cookie, expired state and **different user** are all rejected; denial and exchange failure store nothing; PAT happy path (encrypted, expiry kept); cross-origin POST, bad format and write-scoped or unknown PATs are rejected; session required. - `bun run dev` + curl: anonymous `/connectors`, `/auth/gitlab/start` and the PAT POST redirect to `/login?next=/connectors`; the callback without a session gives `?gitlab=error&reason=signed_out`. - **Not yet run against real GitLab.** That needs the GitLab OAuth app (see Needs you) or a PAT pasted into `/connectors` after the browser login. ### Astra review (gpt-6-astra) "No concrete blocking issues found." It confirmed that state and PKCE are bound to the signed-in user, PATs require same-origin and read-only scopes, storage is encrypted, and redirects use configured destinations. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dzenda merged commit 672bc5d101 into t15-mcp-tools 2026-11-14 17:50:06 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!15
No description provided.