feat: harden Claude MCP OAuth transport

This commit is contained in:
2026-06-13 21:05:11 +02:00
parent ed3130ef74
commit 541124e92a
11 changed files with 1377 additions and 68 deletions
+57 -4
View File
@@ -106,12 +106,12 @@ class Settings(BaseSettings):
description="Secret detection mode: off, mask, or block",
)
# OAuth2 configuration (for ChatGPT per-user Gitea authentication)
# OAuth2 configuration (for per-client Gitea authentication)
oauth_mode: bool = Field(
default=False,
description=(
"Enable per-user OAuth2 authentication mode. "
"When true, each ChatGPT user authenticates with their own Gitea account. "
"When true, each client user authenticates with their own Gitea account. "
"GITEA_TOKEN and MCP_API_KEYS are not required in this mode."
),
)
@@ -126,8 +126,9 @@ class Settings(BaseSettings):
oauth_expected_audience: str = Field(
default="",
description=(
"Expected OIDC audience for access tokens. "
"Defaults to GITEA_OAUTH_CLIENT_ID when unset."
"Additional expected OIDC audience for access tokens. The canonical MCP "
"resource URL and the Gitea OAuth client id are always accepted; set this "
"to require an extra audience value."
),
)
oauth_cache_ttl_seconds: int = Field(
@@ -139,6 +140,37 @@ class Settings(BaseSettings):
default="https://hiddenden.cafe/docs/mcp-gitea",
description="Public documentation URL for OAuth-protected MCP resource behavior",
)
oauth_state_secret: str = Field(
default="",
description=(
"Server secret used to HMAC-sign the OAuth proxy state parameter. "
"Required when OAUTH_MODE=true so callback state is tamper-evident."
),
)
oauth_redirect_allowlist_raw: str = Field(
default="",
description=(
"Comma-separated additional allowed client redirect URIs for the OAuth "
"callback proxy. Claude's callback URLs and loopback URIs are always allowed."
),
alias="OAUTH_REDIRECT_ALLOWLIST",
)
dcr_enabled: bool = Field(
default=True,
description=(
"Enable RFC 7591 Dynamic Client Registration at /register. Claude's "
"connectors register dynamically; disable to require manual client_id/secret."
),
)
dcr_storage_path: Path = Field(
default=Path("/var/lib/aegis-mcp/dcr_clients.json"),
description="Path to the JSON file that persists dynamically registered clients",
)
repo_authz_cache_ttl_seconds: int = Field(
default=60,
description="TTL (seconds) for cached per-user repository permission decisions",
ge=1,
)
# Authentication configuration
auth_enabled: bool = Field(
@@ -269,12 +301,28 @@ class Settings(BaseSettings):
"Set ALLOW_INSECURE_BIND=true to explicitly permit this."
)
extra_redirect_uris: list[str] = []
if self.oauth_redirect_allowlist_raw.strip():
extra_redirect_uris = [
value.strip()
for value in self.oauth_redirect_allowlist_raw.split(",")
if value.strip()
]
object.__setattr__(self, "_oauth_redirect_allowlist", extra_redirect_uris)
if self.oauth_mode:
# In OAuth mode, per-user Gitea tokens are used; no shared bot token or API keys needed.
if not self.gitea_oauth_client_id.strip():
raise ValueError("GITEA_OAUTH_CLIENT_ID is required when OAUTH_MODE=true.")
if not self.gitea_oauth_client_secret.strip():
raise ValueError("GITEA_OAUTH_CLIENT_SECRET is required when OAUTH_MODE=true.")
# The proxy state parameter carries the client's redirect_uri across the Gitea
# round-trip; it must be HMAC-signed, which requires a server-held secret.
if not self.oauth_state_secret.strip():
raise ValueError(
"OAUTH_STATE_SECRET is required when OAUTH_MODE=true so the OAuth "
"proxy state parameter can be HMAC-signed and verified."
)
else:
# Standard API key mode: require bot token and at least one API key.
if not self.gitea_token.strip():
@@ -308,6 +356,11 @@ class Settings(BaseSettings):
"""Get parsed list of repositories allowed for write-mode operations."""
return list(getattr(self, "_write_repository_whitelist", []))
@property
def oauth_redirect_allowlist(self) -> list[str]:
"""Get parsed list of additional allowed client redirect URIs."""
return list(getattr(self, "_oauth_redirect_allowlist", []))
@property
def gitea_base_url(self) -> str:
"""Get Gitea base URL as normalized string."""