Authorization server for MCP clients (SDK provider with dynamic client registration, PKCE, refresh rotation and revocation) that signs users in through Gitea and runs tool calls with the user's own Gitea token. - consent page with CSRF protection and a browser-binding cookie on the Gitea callback (confused-deputy protection for the shared Gitea app) - encrypted token store (Fernet, HKDF-derived key, 0600, atomic writes, symlink and corrupt-file refusal); our tokens stored only as hashes - tokens bound to the MCP endpoint resource and the gitea scope; the static bearer keeps working next to OAuth - code replay and refresh token reuse revoke the grant - automatic upstream refresh with per-session locking; invalid_grant ends the session and never falls back to the admin token - OAuth-only deployments (no GITEA_TOKEN, no bearer) may start - rate limit on the OAuth endpoints, query strings dropped from access logs
74 lines
4.1 KiB
Markdown
74 lines
4.1 KiB
Markdown
# ADR 0004: admin-token mode and per-user OAuth mode
|
|
|
|
Status: accepted (2026-10-14)
|
|
|
|
## Context
|
|
|
|
Two authentication models must coexist on one deployment: an admin with full
|
|
access, and other users who log in as themselves. Gitea is an OAuth2 provider
|
|
but has no RFC 8414 metadata and no dynamic client registration; Mistral Le
|
|
Chat requires dynamic client registration and Claude prefers it. Details:
|
|
`docs/research/sdk-oauth-gitea.md` sections 4 and 6.
|
|
|
|
## Decision
|
|
|
|
- **Admin-token mode (default).** `GITEA_TOKEN` is used for every call. stdio
|
|
always runs in this mode. On HTTP, a request carrying the static
|
|
`MCP_BEARER_TOKEN` (constant-time comparison) runs in this mode.
|
|
- **Per-user OAuth mode (HTTP).** The server is an OAuth 2.1 authorization
|
|
server for MCP clients, using the SDK's `OAuthAuthorizationServerProvider`
|
|
with dynamic client registration, and a confidential OAuth2 client of Gitea
|
|
upstream (authorization code with PKCE S256, fixed and validated scope list).
|
|
Our `/oauth/callback` exchanges the Gitea code, then issues our own opaque
|
|
access and refresh tokens mapped to the user's Gitea tokens. Tool calls use
|
|
the user's Gitea access token, so Gitea enforces that user's permissions;
|
|
admin tools fail with Gitea's own 403 for non-admins. Upstream refresh is
|
|
automatic.
|
|
- **Token store.** A JSON file under `DATA_DIR` (default
|
|
`~/.local/state/aegis-gitea-mcp`, `/var/lib/aegis-mcp` in the image), mode
|
|
0600, values encrypted with Fernet using a key derived from
|
|
`TOKEN_ENCRYPTION_KEY`. Our own tokens are stored only as SHA-256 hashes.
|
|
- **Combined verifier.** One `TokenVerifier` accepts the static bearer (admin
|
|
identity) or an issued OAuth token (user identity). The SDK requires passing
|
|
it explicitly on the low-level server.
|
|
- **Identity in results.** Every tool result carries `identity: {login, mode,
|
|
sudo}`.
|
|
|
|
## Consequences
|
|
|
|
The server holds per-user Gitea tokens. That is the most sensitive asset after
|
|
the admin token and is covered by the threat model.
|
|
|
|
## Implementation notes (chunk B5)
|
|
|
|
- **OAuth-only deployments are allowed.** With `OAUTH_ENABLED=true`, no
|
|
`MCP_BEARER_TOKEN` and no `GITEA_TOKEN`, the server starts and every user
|
|
brings their own Gitea token. Admin-token mode is then unavailable and fails
|
|
closed. `GITEA_TOKEN` stays required whenever admin-token mode is reachable
|
|
(a static bearer, or an unauthenticated loopback endpoint).
|
|
- **Own consent step.** Gitea remembers consent for our single Gitea
|
|
application, so a dynamically registered client with an attacker's redirect
|
|
URI could otherwise obtain a token for a victim who merely opens a link (the
|
|
confused-deputy problem of OAuth proxies). `/authorize` therefore leads to our
|
|
consent page, which names the client and the redirect URI. Approving needs a
|
|
CSRF token bound by a `SameSite=Strict` cookie and a matching `Origin`, and
|
|
sets a browser-binding cookie that `/oauth/callback` requires, so a Gitea
|
|
redirect URL obtained by someone else cannot be completed in another browser.
|
|
- **Token binding.** Tokens are bound to the MCP endpoint URL (RFC 8707) and
|
|
carry the single scope `gitea`; the SDK enforces both
|
|
(`validate_token_resource=True`, `required_scopes=["gitea"]`), and the static
|
|
bearer reports the same resource and scope. `/authorize` with another
|
|
`resource` is refused with `invalid_target`.
|
|
- **Replay handling.** A replayed authorization code, or a rotated-out refresh
|
|
token presented again, revokes the whole grant (RFC 6749 4.1.2, OAuth 2.1
|
|
refresh token reuse detection). Revoking either token ends the grant.
|
|
- **Store key.** The Fernet key is HKDF-SHA256(`TOKEN_ENCRYPTION_KEY`,
|
|
info `aegis-gitea-mcp oauth store`), so the setting can be any long random
|
|
string. Changing it makes the store unreadable; startup then stops instead of
|
|
wiping the file.
|
|
- **Limits.** At most 500 registered clients (the least recently used client
|
|
without a session is evicted, otherwise registration is refused), 50
|
|
sessions per Gitea login (the oldest ends), and 1024 pending sign-ins. The
|
|
unauthenticated OAuth endpoints share the per-IP rate limit, and query
|
|
strings are removed from access log lines.
|