agentic-community / agentic-community/mcp-gateway-registry
Bearer token TTL: MAX_TOKEN_LIFETIME_HOURS not enforced, and request for per-MCP max lifetime at registration
- 主要語言
- Python
- 星號
- 911
- 分支
- 234
- 平均合併
- 1 天 11 小時
- 30 天內合併 PR
- 62
描述
## Summary
Two related issues with the `/api/tokens/generate` endpoint that affect the bearer token issued by the "Connect" button on the registry UI:
1. **Bug**: `MAX_TOKEN_LIFETIME_HOURS` is declared but never enforced, and `expires_in_hours` in the request payload is ignored on the `oauth2` / `network-trusted` code path — the lifetime is hardcoded to 8 hours regardless of what the caller asked for.
2. **Feature request**: allow a **per-MCP-server maximum token lifetime** set at server registration time, so operators can allow long-lived tokens for low-risk servers (e.g. public docs search) while keeping sensitive servers on short TTLs.
Background context: for IDE-based MCP usage (Claude Code, Cursor, Kiro, etc.), an 8-hour bearer token means users must click Connect, copy the JSON, paste into `mcp.json`, and restart their IDE multiple times per day. MCP clients don't hot-reload config, so there's no mitigation short of longer-lived tokens or an out-of-band refresh mechanism.
---
## Part 1: Bug — `MAX_TOKEN_LIFETIME_HOURS` not enforced and `expires_in_hours` ignored
### Current behavior
In [`auth_server/server.py`](https://github.com/agentic-community/mcp-gateway-registry/blob/main/auth_server/server.py):
```python
# Lines 75-76
MAX_TOKEN_LIFETIME_HOURS = 24
DEFAULT_TOKEN_LIFETIME_HOURS = 8
```
```python
# Lines 965-971 - request model accepts an override
class GenerateTokenRequest(BaseModel):
user_context: dict[str, Any]
requested_scopes: list[str] = []
expires_in_hours: int = DEFAULT_TOKEN_LIFETIME_HOURS # accepts override from caller
description: str | None = None
```
```python
# Line 2219 - ignores the request value
if auth_method in ("oauth2", "network-trusted"):
...
expires_in = DEFAULT_TOKEN_LIFETIME_HOURS * 3600 # always 8 hours, ignores request.expires_in_hours
```
`MAX_TOKEN_LIFETIME_HOURS = 24` is declared but never referenced anywhere in the codebase, so it enforces nothing.
### Impact
- Callers of `POST /api/tokens/generate` that pass `expires_in_hours: N` silently get 8h regardless
- The frontend **"Token Ready"** modal shown on Connect always says "expires in 8 hours" because the backend can't return anything else
- There is no way for an operator to raise the default TTL via environment variables, config, or UI
- The TokenGeneration page (`frontend/src/pages/TokenGeneration.tsx`) exposes an input for the user to pick a lifetime, but that value is silently ignored by the backend
### Proposed fix
Make both constants env-var driven and honor the request value, clamped to `MAX_TOKEN_LIFETIME_HOURS`.
**`auth_server/server.py`** (around lines 75-76):
```python
MAX_TOKEN_LIFETIME_HOURS = int(os.environ.get("MAX_TOKEN_LIFETIME_HOURS", "24"))
DEFAULT_TOKEN_LIFETIME_HOURS = int(os.environ.get("DEFAULT_TOKEN_LIFETIME_HOURS", "8"))
```
**`auth_server/server.py`** (around line 2219):
```python
if auth_method in ("oauth2", "network-trusted"):
...
current_time = int(time.time())
# Honor requested TTL, clamped to MAX_TOKEN_LIFETIME_HOURS (and >= 1h)
requested_hours = min(max(request.expires_in_hours, 1), MAX_TOKEN_LIFETIME_HOURS)
expires_in = requested_hours * 3600
if requested_hours != request.expires_in_hours:
logger.info(
f"Clamped requested TTL {request.expires_in_hours}h to {requested_hours}h "
f"(MAX_TOKEN_LIFETIME_HOURS={MAX_TOKEN_LIFETIME_HOURS})"
)
```
**Frontend** — surface the backend-returned `expires_in` in the banner instead of hardcoding "8 hours":
- `frontend/src/components/ServerConfigModal.tsx:352` — replace the static "Token expires in 8 hours" copy with a derived value from the `expires_in` field returned by `/api/tokens/generate`.
- `frontend/src/components/ServerConfigModal.tsx:54`, `frontend/src/components/Sidebar.tsx:98`, `frontend/src/pages/TokenGeneration.tsx:10` — consider reading the default from the registry config API rather than hardcoding `8`.
**Helm / Terraform / Docker Compose wiring** — expose the two new env vars the same way other auth-server env vars are wired today.
---
## Part 2: Feature — per-MCP-server max token lifetime at registration
### Motivation
Not all MCP servers carry the same risk profile. A read-only "AWS Knowledge" search server may be safe to access with a 30-day token, while a server that can mutate production resources should be limited to a 1-hour token. A **single universal** `MAX_TOKEN_LIFETIME_HOURS` can't express this — operators are forced to pick one value that is either too short for convenience or too long for risk.
### Proposed design
Add an optional `max_token_lifetime_hours` field to server metadata, set at registration time (API + UI). Enforcement layers:
1. **Registration (registry)**
- Extend the server document schema with `max_token_lifetime_hours: int | None = None` (`None` = inherit universal `MAX_TOKEN_LIFETIME_HOURS`).
- Add a field on the server register/update form and the API request body.
- Validate at write time: `1 <= max_token_lifetime_hours <= MAX_TOKEN_LIFETIME_HOURS`.
2. **Token mint (auth server)**
- The **"Connect" modal** calls `/api/tokens/generate` in the context of a single server. Pass the server identifier (e.g. `server_path` or `server_name`) in the request body.
- In `auth_server/server.py` token generation, look up the server's `max_token_lifetime_hours` via the registry (or via an in-memory cache refreshed the same way scopes are).
- Effective cap = `min(universal_MAX_TOKEN_LIFETIME_HOURS, server.max_token_lifetime_hours)`.
- Tokens minted for a multi-server context (e.g. the sidebar "Get JWT Token" which doesn't target a single server) still use the universal cap.
3. **UI**
- Connect modal reads `server.max_token_lifetime_hours` and requests that value (or the server-specific max).
- Banner text shows the actual TTL returned by the backend.
- TokenGeneration page input should clamp to the per-server cap when a server is selected.
### API sketch
`POST /api/servers` body:
```json
{
"name": "AWS Knowledge",
"path": "/aws-knowledge/",
...,
"max_token_lifetime_hours": 720
}
```
`POST /api/tokens/generate` body (additive, backwards-compatible):
```json
{
"description": "Generated for MCP configuration",
"expires_in_hours": 720,
"server_context": {
"server_path": "/aws-knowledge/"
}
}
```
If `server_context` is omitted, the universal cap applies as today.
### Audit & observability
- Log the effective cap and the reason (`universal` vs `per-server-`) on every mint.
- Include the server identifier in the token's `description` / audit record so compromised tokens can be traced to the server they were minted for.
---
## Security considerations
- Per-server long-lived tokens still carry the caller's groups baked in at mint time — there is no revocation today. A natural follow-up would be a **token revocation list** keyed on `jti`.
- The **universal** `MAX_TOKEN_LIFETIME_HOURS` should act as a hard ceiling: a per-server setting can only make the cap *shorter*, never longer.
- Admin-scoped tokens (holders of `mcp-registry-admin`) probably warrant an even shorter ceiling regardless of per-server config — consider an additional `ADMIN_MAX_TOKEN_LIFETIME_HOURS` guardrail.
---
## Acceptance criteria
**Part 1 (bug):**
- [ ] `MAX_TOKEN_LIFETIME_HOURS` and `DEFAULT_TOKEN_LIFETIME_HOURS` read from env vars with sensible defaults.
- [ ] `/api/tokens/generate` honors `request.expires_in_hours`, clamped to `MAX_TOKEN_LIFETIME_HOURS`, with a log line when clamped.
- [ ] Frontend banner text reflects the actual `expires_in` returned from the API, not a hardcoded string.
- [ ] Unit tests cover: exact request honored, clamp applied, minimum of 1h enforced.
- [ ] Env vars documented in `docs/registry-api-auth.md` and wired through Docker Compose / Helm / Terraform.
**Part 2 (feature):**
- [ ] `max_token_lifetime_hours` field added to server schema and registration UI/API.
- [ ] `/api/tokens/generate` accepts an optional `server_context` and applies the per-server cap.
- [ ] Effective cap is `min(universal, per-server)`.
- [ ] Backwards compatible — absence of `server_context` uses universal cap; absence of `max_token_lifetime_hours` on a server uses universal cap.
- [ ] UI shows the per-server cap on the Connect modal and TokenGeneration page.
- [ ] Documentation updated in `docs/registry-api-auth.md`.
---
## Suggested labels
`bug`, `enhancement`, `authentication`, `oauth`, `dx`
貢獻指南
評估
這個 Issue 還沒有評估資料。