fix: keep OAuth flow working on read-only container roots
The DCR client registry created its storage directory eagerly in __init__,
and DCR_STORAGE_PATH defaulted to /var/lib/aegis-mcp — a path that is neither
created in the image nor mounted as a writable volume. Under the hardened
read-only docker-compose, every /oauth/authorize, /oauth/token, and /register
call hit `mkdir('/var/lib/aegis-mcp')` on a read-only filesystem, raising an
unhandled OSError and returning a bare "Internal Server Error" during login.
- oauth_flow.py: defer the storage-dir mkdir from __init__ to _persist (the
only write path). authorize/token only read the registry, so they no longer
require a writable filesystem and stop 500-ing.
- docker/Dockerfile: create and chown /var/lib/aegis-mcp.
- docker-compose.yml + docker/docker-compose.yml: add a persistent
aegis-mcp-data volume mounted at /var/lib/aegis-mcp so DCR registrations
survive restarts.
- .env.example: document DCR_STORAGE_PATH and set PUBLIC_BASE_URL to the real
MCP host.
- README.md: spell out exact values (Gitea host, MCP host, callback URL, MCP
URL) and add a "required writable volumes" section explaining the cause of
the login 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
-4
@@ -16,14 +16,21 @@ OAUTH_STATE_SECRET=
|
||||
OAUTH_EXPECTED_AUDIENCE=
|
||||
# OIDC discovery and JWKS cache TTL
|
||||
OAUTH_CACHE_TTL_SECONDS=300
|
||||
# Where dynamically registered OAuth clients (RFC 7591 /register) are stored.
|
||||
# This file MUST live on a writable, persistent volume. The default below is
|
||||
# mounted as the `aegis-mcp-data` volume in docker-compose; if you run the
|
||||
# container read-only without that volume the OAuth flow returns 500 because the
|
||||
# directory is not writable. Point this at any writable path if you deploy
|
||||
# differently.
|
||||
DCR_STORAGE_PATH=/var/lib/aegis-mcp/dcr_clients.json
|
||||
|
||||
# MCP server configuration
|
||||
MCP_HOST=127.0.0.1
|
||||
MCP_PORT=8080
|
||||
# Optional external URL used in OAuth metadata and callback URLs when running behind a
|
||||
# reverse proxy. When unset, the server derives these from the incoming request.
|
||||
# Example: PUBLIC_BASE_URL=https://gitea-mcp.hiddenden.cafe
|
||||
PUBLIC_BASE_URL=
|
||||
# Public, externally-reachable base URL of THIS MCP server (no trailing slash).
|
||||
# Used to build OAuth metadata and the /oauth/callback URL behind a reverse proxy.
|
||||
# This is the host you give to Claude (its MCP URL is PUBLIC_BASE_URL + /mcp).
|
||||
PUBLIC_BASE_URL=https://gitea-mcp.hiddenden.cafe
|
||||
ALLOW_INSECURE_BIND=false
|
||||
|
||||
# Logging / observability
|
||||
|
||||
@@ -6,14 +6,25 @@ AegisGitea-MCP exposes MCP tools over Streamable HTTP and a legacy SSE alias. Ea
|
||||
|
||||
## Securing MCP with Gitea OAuth
|
||||
|
||||
This guide uses the live deployment values as the running example:
|
||||
|
||||
| Thing | Value |
|
||||
|-------|-------|
|
||||
| Gitea instance (`GITEA_URL`) | `https://git.hiddenden.cafe` |
|
||||
| This MCP server (`PUBLIC_BASE_URL`) | `https://gitea-mcp.hiddenden.cafe` |
|
||||
| OAuth callback to register in Gitea | `https://gitea-mcp.hiddenden.cafe/oauth/callback` |
|
||||
| MCP URL you give to Claude | `https://gitea-mcp.hiddenden.cafe/mcp` |
|
||||
|
||||
Substitute your own hostnames if they differ. The two URLs are **different hosts**: `git.*` is Gitea, `gitea-mcp.*` is this proxy.
|
||||
|
||||
### 1) Create a Gitea OAuth2 application
|
||||
|
||||
1. Open `https://git.hiddenden.cafe/user/settings/applications` (or admin application settings).
|
||||
2. Create an OAuth2 app.
|
||||
3. Set the redirect URI to this MCP server's callback: `https://<your-mcp-domain>/oauth/callback`.
|
||||
4. Save the app and keep:
|
||||
- `Client ID`
|
||||
- `Client Secret`
|
||||
3. Set the redirect URI to **this MCP server's callback** (not Gitea's own host):
|
||||
`https://gitea-mcp.hiddenden.cafe/oauth/callback`
|
||||
This is the only redirect URI Gitea needs — the MCP server forwards each client's real callback through a signed state parameter.
|
||||
4. Save the app and copy the generated `Client ID` and `Client Secret`.
|
||||
|
||||
Required scopes:
|
||||
- `read:repository`
|
||||
@@ -25,19 +36,41 @@ Required scopes:
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Set OAuth-first values:
|
||||
Fill in exactly these values in `.env` (everything else has safe defaults):
|
||||
|
||||
```env
|
||||
# The Gitea instance this server talks to
|
||||
GITEA_URL=https://git.hiddenden.cafe
|
||||
|
||||
# Per-user OAuth mode (recommended)
|
||||
OAUTH_MODE=true
|
||||
GITEA_OAUTH_CLIENT_ID=<your-client-id>
|
||||
GITEA_OAUTH_CLIENT_SECRET=<your-client-secret>
|
||||
PUBLIC_BASE_URL=https://<your-mcp-domain>
|
||||
GITEA_OAUTH_CLIENT_ID=<client-id-from-step-1>
|
||||
GITEA_OAUTH_CLIENT_SECRET=<client-secret-from-step-1>
|
||||
|
||||
# Public URL of THIS server (no trailing slash). Claude's MCP URL is this + /mcp
|
||||
PUBLIC_BASE_URL=https://gitea-mcp.hiddenden.cafe
|
||||
|
||||
# Secret that signs the OAuth proxy state. Generate with: openssl rand -hex 32
|
||||
OAUTH_STATE_SECRET=<random-32-byte-minimum-secret>
|
||||
|
||||
# Where dynamically-registered OAuth clients are stored — MUST be a writable,
|
||||
# persistent path. The default matches the aegis-mcp-data volume in compose.
|
||||
DCR_STORAGE_PATH=/var/lib/aegis-mcp/dcr_clients.json
|
||||
```
|
||||
|
||||
`GITEA_TOKEN` is optional in OAuth mode. Without it, Gitea REST calls use the user's OAuth access token directly, so Gitea enforces permissions on every API call. With it, the token acts as a service PAT for API execution, but the MCP server first checks the requesting user's permission on the target repository through Gitea and denies the call if the user lacks the required read/write permission.
|
||||
|
||||
### 2a) Required writable volumes (read-only container)
|
||||
|
||||
The provided `docker-compose.yml` runs the container with a **read-only root filesystem**. The server therefore needs two writable volumes, both already wired up in compose:
|
||||
|
||||
| Path | Purpose | Volume |
|
||||
|------|---------|--------|
|
||||
| `/var/log/aegis-mcp` | tamper-evident audit log | `aegis-mcp-logs` |
|
||||
| `/var/lib/aegis-mcp` | dynamic client registration store (`DCR_STORAGE_PATH`) | `aegis-mcp-data` |
|
||||
|
||||
If `/var/lib/aegis-mcp` is **not** writable/persistent, the OAuth `authorize`, `token`, and `register` endpoints fail and the browser shows a bare `Internal Server Error` during login. Keep the `aegis-mcp-data` volume mounted (or point `DCR_STORAGE_PATH` at another writable, persistent location), and make sure it survives restarts so registered clients are not lost.
|
||||
|
||||
### 3) Configure Claude, Claude Code, or Cowork
|
||||
|
||||
Claude's hosted, desktop, mobile, Claude Code, and Cowork surfaces share the same remote MCP connector infrastructure. There is no Claude-specific server code path.
|
||||
@@ -46,20 +79,20 @@ In claude.ai:
|
||||
|
||||
1. Open **Settings > Connectors**.
|
||||
2. Choose **Add custom connector**.
|
||||
3. Paste `https://<your-mcp-domain>/mcp`.
|
||||
3. Paste `https://gitea-mcp.hiddenden.cafe/mcp`.
|
||||
4. Complete the OAuth consent flow. Dynamic Client Registration (`/register`) handles Claude client registration.
|
||||
|
||||
In Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http aegis-gitea https://<your-mcp-domain>/mcp
|
||||
claude mcp add --transport http aegis-gitea https://gitea-mcp.hiddenden.cafe/mcp
|
||||
```
|
||||
|
||||
Cowork uses the same connector model and MCP URL.
|
||||
|
||||
Manual OAuth client configuration remains available for clients that do not use DCR:
|
||||
|
||||
- MCP server URL: `https://<your-mcp-domain>/mcp`
|
||||
- MCP server URL: `https://gitea-mcp.hiddenden.cafe/mcp`
|
||||
- Authentication: OAuth
|
||||
- OAuth client ID: the client id returned by `/register` or your preconfigured client id
|
||||
- OAuth client secret: only for confidential clients
|
||||
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- "8080"
|
||||
volumes:
|
||||
- aegis-mcp-logs:/var/log/aegis-mcp
|
||||
- aegis-mcp-data:/var/lib/aegis-mcp
|
||||
- ./policy.yaml:/app/policy.yaml:ro
|
||||
read_only: true
|
||||
tmpfs:
|
||||
@@ -61,6 +62,7 @@ services:
|
||||
- ./src:/app/src:ro
|
||||
- ./policy.yaml:/app/policy.yaml:ro
|
||||
- aegis-mcp-logs:/var/log/aegis-mcp
|
||||
- aegis-mcp-data:/var/lib/aegis-mcp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -72,6 +74,8 @@ services:
|
||||
volumes:
|
||||
aegis-mcp-logs:
|
||||
driver: local
|
||||
aegis-mcp-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
|
||||
+4
-2
@@ -36,8 +36,10 @@ COPY --from=builder --chown=aegis:aegis /root/.local /home/aegis/.local
|
||||
COPY --chown=aegis:aegis src/ ./src/
|
||||
COPY --chown=aegis:aegis scripts/ ./scripts/
|
||||
|
||||
RUN mkdir -p /var/log/aegis-mcp /tmp/aegis-mcp \
|
||||
&& chown -R aegis:aegis /var/log/aegis-mcp /tmp/aegis-mcp
|
||||
# /var/log/aegis-mcp -> audit log (mount a writable volume)
|
||||
# /var/lib/aegis-mcp -> dynamic client registration store (mount a writable, persistent volume)
|
||||
RUN mkdir -p /var/log/aegis-mcp /var/lib/aegis-mcp /tmp/aegis-mcp \
|
||||
&& chown -R aegis:aegis /var/log/aegis-mcp /var/lib/aegis-mcp /tmp/aegis-mcp
|
||||
|
||||
USER aegis
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
- "127.0.0.1:${MCP_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- aegis-mcp-logs:/var/log/aegis-mcp
|
||||
- aegis-mcp-data:/var/lib/aegis-mcp
|
||||
- ../policy.yaml:/app/policy.yaml:ro
|
||||
read_only: true
|
||||
tmpfs:
|
||||
@@ -42,6 +43,8 @@ services:
|
||||
volumes:
|
||||
aegis-mcp-logs:
|
||||
driver: local
|
||||
aegis-mcp-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
|
||||
@@ -240,9 +240,15 @@ class OAuthClientRegistry:
|
||||
"""Persisted OAuth client registry for dynamic client registration."""
|
||||
|
||||
def __init__(self, storage_path: Path) -> None:
|
||||
"""Initialize registry storage."""
|
||||
"""Initialize registry storage.
|
||||
|
||||
The storage directory is created lazily at write time (see ``_persist``)
|
||||
rather than here, so that read-only operations — the OAuth ``authorize``
|
||||
and ``token`` proxies only ever *read* the registry — never require a
|
||||
writable filesystem. This keeps those endpoints working on hardened,
|
||||
read-only container roots where only an explicit data volume is writable.
|
||||
"""
|
||||
self.storage_path = storage_path
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._clients: dict[str, OAuthClientRecord] = {}
|
||||
self._loaded = False
|
||||
|
||||
@@ -276,7 +282,14 @@ class OAuthClientRegistry:
|
||||
self._clients = clients
|
||||
|
||||
def _persist(self) -> None:
|
||||
"""Write registrations atomically."""
|
||||
"""Write registrations atomically.
|
||||
|
||||
Dynamic client registration is the only operation that writes to disk, so
|
||||
the storage directory is created here (not at construction time). This
|
||||
requires the configured ``DCR_STORAGE_PATH`` to live on a writable,
|
||||
persistent volume — see the deployment notes in the README.
|
||||
"""
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
client_id: record.model_dump(mode="json", exclude={"client_id"})
|
||||
for client_id, record in self._clients.items()
|
||||
|
||||
Reference in New Issue
Block a user