From d7cac91175be66d3728eba99019af93718d70998 Mon Sep 17 00:00:00 2001 From: latte Date: Wed, 14 Oct 2026 17:53:58 +0200 Subject: [PATCH] ci: raise the coverage gate to 90 percent; docs: reconcile OAuth docs with the implementation --- README.md | 11 ++++++++--- docs/architecture.md | 10 ++++++---- docs/configuration.md | 22 ++++++++++++++++++---- docs/deployment.md | 21 ++++++++++++++++----- docs/hardening.md | 8 +++++++- docs/troubleshooting.md | 11 +++++++++-- pyproject.toml | 4 ++-- 7 files changed, 66 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2c4d909..1be22f9 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,14 @@ Both modes can run on one HTTP deployment at the same time (details in calls then run with that user's own Gitea token, so Gitea enforces that user's permissions: admin tools fail with Gitea's own 403 for non-admins. Upstream tokens are refreshed automatically. Stored user tokens are encrypted (Fernet, key derived from - `TOKEN_ENCRYPTION_KEY`) in `DATA_DIR/oauth-tokens.json` with mode 0600; the server's own - tokens are stored only as SHA-256 hashes. The signed-in login is visible in the `identity` - field of every result. + `TOKEN_ENCRYPTION_KEY`) in `DATA_DIR/oauth-store.json` with mode 0600; the server's own + tokens are stored only as SHA-256 hashes. A store written with another key stops startup until + the key is restored or the file is moved away. The signed-in login is visible in the + `identity` field of every result. Before the browser goes to Gitea, a consent page + (`/oauth/consent`) names the client and where the result goes and needs an explicit approval; + for public deployments set `OAUTH_REDIRECT_ALLOWLIST` to the hosted clients you use. An + OAuth-only deployment (no `MCP_BEARER_TOKEN`, no `GITEA_TOKEN`) is supported: it has no admin + identity to fall back to. ## Client configuration diff --git a/docs/architecture.md b/docs/architecture.md index 1b9d506..3b0bd14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,10 +102,12 @@ compared, so length does not leak). A match runs the call as `GITEA_TOKEN`, with resolved once from `GET /user`. stdio is always this mode. Per-user OAuth mode: the server is an OAuth 2.1 authorization server for MCP clients (dynamic -client registration, PKCE) and a confidential OAuth client of Gitea. `/oauth/callback` receives -Gitea's code, exchanges it, and issues the client opaque access and refresh tokens that map to -the user's Gitea tokens. A call with such a token runs with the user's own Gitea access token, -so Gitea enforces that user's permissions. The mapping lives in `DATA_DIR/oauth-tokens.json` +client registration, PKCE) and a confidential OAuth client of Gitea. `/authorize` parks the +request and shows the consent page `/oauth/consent` (client name, redirect URI, approval with +CSRF and origin checks) before the browser goes to Gitea. `/oauth/callback` receives Gitea's +code in the same browser, exchanges it, and issues the client opaque access and refresh tokens +that map to the user's Gitea tokens. A call with such a token runs with the user's own Gitea access token, +so Gitea enforces that user's permissions. The mapping lives in `DATA_DIR/oauth-store.json` (mode 0600). Gitea tokens in it are encrypted with Fernet (key derived from `TOKEN_ENCRYPTION_KEY`); our own tokens are stored as SHA-256 hashes. Upstream refresh is automatic. diff --git a/docs/configuration.md b/docs/configuration.md index 6837019..f4a5838 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -56,22 +56,36 @@ when OAuth is on. | Variable | Default | Rules | |---|---|---| -| `OAUTH_ENABLED` | `false` | When true, the four settings below are required, or the server refuses to start (exit code 2, with the list of what is missing). | +| `OAUTH_ENABLED` | `false` | When true, `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL` are required, or the server refuses to start (exit code 2, with the list of what is missing). | | `GITEA_OAUTH_CLIENT_ID` | unset | Client id of the Gitea OAuth application (created by `setup`). | | `GITEA_OAUTH_CLIENT_SECRET` | unset | Its client secret. | -| `TOKEN_ENCRYPTION_KEY` | unset | At least 32 characters. Encrypts stored user tokens. Losing it invalidates every stored login. | +| `TOKEN_ENCRYPTION_KEY` | unset | At least 32 characters. Encrypts the token store. The key cannot be rotated in place: a store written with another key stops startup (exit code 2) instead of being wiped, until you restore the original key or move `DATA_DIR/oauth-store.json` away, which signs every user out. | | `OAUTH_SCOPES` | all `write:` scopes | Granular Gitea scopes only (`read:` or `write:` plus activitypub, admin, issue, misc, notification, organization, package, repository, user). Anything else is rejected, because Gitea grants full access for unknown scope strings. Must not be empty. | - | `OAUTH_REDIRECT_ALLOWLIST` | empty | Comma list of redirect URIs MCP clients may register: exact URIs (`https://client.example/cb`) or prefixes ending in `/` that allow a whole host (`https://claude.ai/`). A bare origin without a path is refused. Empty accepts any https URI (and http on `localhost`, `127.0.0.1`, `[::1]`). With a list, https URIs must match it; loopback URIs stay allowed for native clients. Checked at registration and again at `/authorize`. | `PUBLIC_BASE_URL` is also required for OAuth. The Gitea OAuth application's redirect URI is `/oauth/callback`. +OAuth-only deployments are supported: with `OAUTH_ENABLED=true` and neither `MCP_BEARER_TOKEN` +nor `GITEA_TOKEN`, every user brings their own Gitea token and admin-token mode is simply not +available (nothing falls back to it). `GITEA_TOKEN` stays required whenever admin-token mode is +reachable: with a static bearer, without any HTTP authentication, and on stdio. + +Sign-in flow and limits: `/authorize` sends the browser to the consent page `/oauth/consent`, +which names the client and the redirect URI and needs an explicit approval (CSRF token, cookie +and an `Origin` that matches `PUBLIC_BASE_URL`) before the browser goes to Gitea; the callback +`/oauth/callback` completes only in that same browser. Issued `agm_` access tokens live 1 hour +and refresh tokens 30 days (rotated on use, reuse revokes the grant); authorization codes live 5 +minutes and pending sign-ins 10 minutes. Bounds: 500 registered clients (10 redirect URIs and 8 +KiB of metadata each), 1024 pending sign-ins and codes, 50 sessions per Gitea login, and per +client address 20 pending sign-ins and 20 registrations per hour. The consent form body is +limited to 16 KiB and must be `application/x-www-form-urlencoded`. + ## State | Variable | Default | Rules | |---|---|---| -| `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the token store (`oauth-tokens.json`, mode 0600) and the audit log. | +| `DATA_DIR` | `~/.local/state/aegis-gitea-mcp` (`/var/lib/aegis-mcp` in the image) | `~` is expanded. Holds the encrypted token store (`oauth-store.json`, mode 0600) and the audit log. A new directory is created with mode 0700; an existing one must belong to the running user and is tightened to 0700, otherwise OAuth startup fails. | | `AUDIT_LOG_PATH` | `DATA_DIR/audit.log` | Path of the hash-chained audit log. See [audit-logging.md](audit-logging.md). The server opens it for append at startup and exits with code 2 and a message on stderr when it cannot. | ## Tool exposure diff --git a/docs/deployment.md b/docs/deployment.md index d5e42f6..b026792 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,8 +9,12 @@ hands the power of its Gitea token to every client that can authenticate. - Admin-token only: set `GITEA_TOKEN` and `MCP_BEARER_TOKEN`. Every client with the bearer token acts as the token owner. - Per-user OAuth: additionally set `OAUTH_ENABLED=true` with the OAuth settings. Users sign in - with their own Gitea account and act under their own permissions. The static bearer keeps - working next to it as the admin identity; leave `MCP_BEARER_TOKEN` unset to turn that off. + with their own Gitea account (after a consent step on `/oauth/consent`) and act under their + own permissions. The static bearer keeps working next to it as the admin identity; leave + `MCP_BEARER_TOKEN` unset to turn that off. +- OAuth only: `OAUTH_ENABLED=true` with neither `MCP_BEARER_TOKEN` nor `GITEA_TOKEN`. The server + starts and serves signed-in users only; admin-token mode does not exist in this deployment, so + no request can ever fall back to an admin identity. Recommended for public deployments. ## Docker @@ -50,7 +54,7 @@ Points that matter: - The port is published on `127.0.0.1` only. The reverse proxy on the same host is the public entry point. - `DATA_DIR` must be a persistent volume writable by the container user. It holds the audit log - and the encrypted OAuth token store (`oauth-tokens.json`, mode 0600). Losing the volume loses + and the encrypted OAuth token store (`oauth-store.json`, mode 0600). Losing the volume loses all OAuth logins and the audit history. Back it up, and protect the backup like the secrets it protects. - Keep `.env` out of version control and out of the image (mode 0600; `setup` writes it that @@ -104,6 +108,12 @@ Gitea has no instance-wide OAuth application API, so the application belongs to that created it. Re-running setup reuses it; if the stored client secret is missing, Gitea has to regenerate it, and setup asks before doing so. +Before the browser is sent to Gitea, the consent page `/oauth/consent` shows the client name and +the redirect URI, because Gitea remembers consent for this one application and any client can +register itself. For a public deployment also set `OAUTH_REDIRECT_ALLOWLIST` to the hosted +clients you use, for example `https://claude.ai/,https://chat.mistral.ai/` (see +[configuration.md](configuration.md)). + `OAUTH_SCOPES` is the scope list sent to Gitea. Keep it to granular scopes. Narrow it (for example to `read:repository,read:issue`) to limit what signed-in users' tokens can do through this server regardless of their Gitea rights. @@ -111,8 +121,9 @@ server regardless of their Gitea rights. ## Upgrades - Application: pull the new image (or package) and restart. The data volume is kept. -- `TOKEN_ENCRYPTION_KEY` must stay the same, or stored OAuth logins can no longer be decrypted - and users have to sign in again. +- `TOKEN_ENCRYPTION_KEY` must stay the same. A store written with another key stops the server + at startup (exit code 2, the file is kept) instead of being wiped: restore the old key, or move + `DATA_DIR/oauth-store.json` away to start over, which signs every OAuth user out. - Upgrading Gitea: tool names and schemas come from the pinned swagger, so a Gitea upgrade does not change them. To pick up a newer API, re-pin the swagger and regenerate the catalog: diff --git a/docs/hardening.md b/docs/hardening.md index 29ff721..d790dee 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -27,7 +27,13 @@ authentication), but it does not decide what is safe. This checklist is how you header). - `TOKEN_ENCRYPTION_KEY` protects the stored per-user tokens. Keep it separate from the data volume backups. `GITEA_OAUTH_CLIENT_SECRET` likewise. -- Use `setup`, which writes the env file with mode 0600 and never prints secrets. +- Use `setup`, which writes the env file with mode 0600 and never prints secrets. The server + loads `./.env` only while it is a regular file you own with mode 0600; keep it elsewhere with + `--env-file` or `AEGIS_ENV_FILE`. +- For a public OAuth deployment set `OAUTH_REDIRECT_ALLOWLIST` (for example + `https://claude.ai/,https://chat.mistral.ai/`), so only the hosted clients you use can + register, and leave `MCP_BEARER_TOKEN` and `GITEA_TOKEN` unset when nobody needs the admin + identity. - Do not paste tokens into chat with the assistant, and do not commit `.env`. ## Network and transport diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e5eddaf..4db1e2c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -13,6 +13,8 @@ Gitea's sanitized message, and the audit log has one event per call (see | Exit code 2, "env file ... does not exist" | The file named by `--env-file` or `AEGIS_ENV_FILE` is missing or not a regular file. | | Exit code 2, refuses a non-loopback bind | `MCP_HOST` is not loopback and there is no `MCP_BEARER_TOKEN` or complete OAuth setup. Set one of them, or bind `127.0.0.1` behind a reverse proxy. | | Exit code 2, lists OAuth settings | `OAUTH_ENABLED=true` needs `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY` and `PUBLIC_BASE_URL`. | +| Exit code 2, "cannot decrypt .../oauth-store.json" | `TOKEN_ENCRYPTION_KEY` differs from the key the store was written with (or the file is damaged). The server does not wipe it. Restore the original key, or move `DATA_DIR/oauth-store.json` away to start over (signs every OAuth user out). | +| Exit code 2, "owned by another user" or "writable by group or others" (OAuth) | `DATA_DIR` must belong to the user the server runs as and not be group or world writable. Fix the owner or `chmod 700`. | | Validation error naming a setting | See the rules in [configuration.md](configuration.md): URLs must be http(s) without credentials, `PUBLIC_BASE_URL` must be https, secrets need 32 characters, `OAUTH_SCOPES` accepts only granular scopes. | | Startup error listing tool names | `DISABLED_TOOLS` contains names that are not registered. Check [tools.md](tools.md). | @@ -69,8 +71,13 @@ A tool the client cannot see is probably filtered by `TOOLSETS`, `READ_ONLY` or - Gitea shows a redirect URI error: the OAuth application's redirect URI must be exactly `/oauth/callback`. Re-run `setup --public-base-url ...` or fix it in Gitea. - Signed in, but admin tools return 403: the user is not a site administrator. That is intended. -- Users are asked to sign in again after a restart: `TOKEN_ENCRYPTION_KEY` or the `DATA_DIR` - volume changed. The key must stay stable and the volume persistent. +- Users are asked to sign in again after a restart: the `DATA_DIR` volume changed or was not + persistent (a changed `TOKEN_ENCRYPTION_KEY` stops startup instead, see above). Keep the key + stable and the volume persistent. +- The sign-in page says the request is invalid or expired: the consent page lives 10 minutes + and a form is single use. Start the sign-in again from the client. Registration or sign-in + refused with "too many": one client address may hold 20 pending sign-ins and register 20 + clients per hour. - After fixing OAuth settings the client still fails: some clients cache tokens. Disconnect the server in the client and connect again, which starts a fresh flow. Users can also revoke the application under Settings, Applications, Authorized OAuth2 Applications in Gitea. diff --git a/pyproject.toml b/pyproject.toml index 429710e..502b0e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,7 +127,7 @@ disallow_untyped_defs = false [tool.pytest.ini_options] minversion = "8.0" -addopts = "-ra -q --strict-markers --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=80" +addopts = "-ra -q --strict-markers --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=90" testpaths = ["tests"] pythonpath = ["src"] asyncio_mode = "auto" @@ -141,7 +141,7 @@ source = ["src"] omit = ["tests/*", "**/__pycache__/*"] [tool.coverage.report] -fail_under = 80 +fail_under = 90 exclude_lines = [ "pragma: no cover", "def __repr__",