Skip to content

Commit a3c9ebc

Browse files
committed
Simplify auth to OIDCProxy only
Remove MultiAuth, BearerTokenVerifier, and generate_api_key. Claude.ai connects via OAuth flow; Claude Code uses Claude.ai. Only OIDCProxy needed — no bearer token or JWT patterns.
1 parent 46393ef commit a3c9ebc

7 files changed

Lines changed: 65 additions & 237 deletions

File tree

src/things_mcp/auth.py

Lines changed: 6 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,37 @@
11
"""Authentication for the MCP server.
22
3-
Supports two authentication modes simultaneously via MultiAuth:
4-
5-
1. **Keycloak OIDC** (for Claude.ai connectors and other OAuth clients):
6-
The server proxies the full OAuth flow via OIDCProxy using pre-registered
7-
Keycloak client credentials. No Dynamic Client Registration (DCR) needed.
8-
9-
2. **Bearer token** (for Claude Code, n8n, and other direct clients):
10-
Simple static API key validation via Authorization: Bearer <key>.
11-
12-
The API key is configured via the THINGS_MCP_API_KEY environment variable
13-
or .env file. If not set, the server generates one on first startup and
14-
saves it to .env automatically.
3+
Uses OIDCProxy to proxy the OAuth flow to Keycloak using pre-registered
4+
client credentials. Claude.ai (and Claude Code through it) authenticates
5+
via the standard authorization_code flow.
156
"""
167

17-
import hmac
18-
import secrets
19-
20-
from fastmcp.server.auth import (
21-
AccessToken,
22-
MultiAuth,
23-
TokenVerifier,
24-
)
258
from fastmcp.server.auth.oidc_proxy import OIDCProxy
269

2710
from .logging_config import get_logger
2811

2912
logger = get_logger(__name__)
3013

3114

32-
class BearerTokenVerifier(TokenVerifier):
33-
"""Validates incoming requests against a static API key.
34-
35-
Uses constant-time comparison to prevent timing attacks.
36-
"""
37-
38-
def __init__(self, api_key: str):
39-
super().__init__()
40-
self._api_key = api_key
41-
42-
async def verify_token(self, token: str) -> AccessToken | None:
43-
if not hmac.compare_digest(token, self._api_key):
44-
logger.warning("Rejected request with invalid API key")
45-
return None
46-
47-
return AccessToken(
48-
token=token,
49-
client_id="things-mcp-client",
50-
scopes=["all"],
51-
)
52-
53-
5415
def create_auth(
55-
api_key: str | None,
5616
base_url: str,
5717
keycloak_issuer: str,
58-
keycloak_audience: str,
5918
keycloak_client_id: str,
6019
keycloak_client_secret: str,
61-
) -> MultiAuth:
62-
"""Create the authentication provider.
63-
64-
Returns a MultiAuth that accepts both:
65-
- Keycloak OIDC clients (Claude.ai) via OIDCProxy (server-side OAuth)
66-
- Bearer token clients (Claude Code, n8n) via static API key
20+
) -> OIDCProxy:
21+
"""Create the OIDCProxy authentication provider.
6722
6823
Args:
69-
api_key: Static API key for bearer token auth (None to skip).
7024
base_url: Public URL of this server (e.g. https://things.example.com).
7125
keycloak_issuer: Keycloak realm issuer URL
7226
(e.g. https://auth.cdit-works.de/realms/cdit-mcp).
73-
keycloak_audience: Expected JWT audience claim (e.g. mcp-things).
7427
keycloak_client_id: Pre-registered Keycloak client ID.
7528
keycloak_client_secret: Keycloak client secret.
7629
"""
7730
config_url = f"{keycloak_issuer}/.well-known/openid-configuration"
7831

79-
oidc_auth = OIDCProxy(
32+
return OIDCProxy(
8033
config_url=config_url,
8134
client_id=keycloak_client_id,
8235
client_secret=keycloak_client_secret,
8336
base_url=base_url,
8437
)
85-
86-
verifiers: list[TokenVerifier] = []
87-
if api_key:
88-
verifiers.append(BearerTokenVerifier(api_key))
89-
90-
return MultiAuth(server=oidc_auth, verifiers=verifiers)
91-
92-
93-
def generate_api_key() -> str:
94-
"""Generate a cryptographically secure API key."""
95-
return f"tmcp_{secrets.token_urlsafe(32)}"

src/things_mcp/config.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -184,30 +184,6 @@ def _write_token_to_env(token: str) -> bool:
184184
return _write_env_var(_find_env_file(), "THINGS_AUTH_TOKEN", token)
185185

186186

187-
def ensure_api_key() -> tuple[str, bool]:
188-
"""Ensure a server API key exists; generate and save one if not.
189-
190-
Returns (api_key, is_new) — is_new is True only on first generation.
191-
"""
192-
from .auth import generate_api_key
193-
194-
settings = get_settings()
195-
if settings.has_api_key:
196-
return settings.things_mcp_api_key, False
197-
198-
new_key = generate_api_key()
199-
logger.info("No THINGS_MCP_API_KEY found — generating one automatically")
200-
201-
env_path = _find_env_file()
202-
_write_env_var(env_path, "THINGS_MCP_API_KEY", new_key)
203-
logger.info(f"API key saved to {env_path}")
204-
205-
os.environ["THINGS_MCP_API_KEY"] = new_key
206-
get_settings.cache_clear()
207-
208-
return new_key, True
209-
210-
211187
def enforce_file_permissions() -> None:
212188
"""Check and fix permissions on sensitive files at startup."""
213189
files_0600 = [

src/things_mcp/fast_server.py

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
from .url_scheme import launch_things
3939
from .config import (
4040
ensure_auth_token,
41-
ensure_api_key,
4241
enforce_file_permissions,
4342
)
4443
from .logging_config import setup_logging, get_logger
@@ -56,9 +55,6 @@
5655
setup_logging(console_level=_console_level, file_level="DEBUG", structured_logs=True)
5756
logger = get_logger(__name__)
5857

59-
# Ensure API key exists before creating server (so auth provider gets it)
60-
_api_key, _api_key_is_new = ensure_api_key()
61-
6258
# Enforce secure file permissions on startup
6359
enforce_file_permissions()
6460

@@ -453,9 +449,10 @@ def signal_handler(signum, frame):
453449
HOST_ENV_VAR,
454450
)
455451
else:
456-
if _api_key:
452+
settings = get_settings()
453+
if settings.keycloak_client_secret:
457454
logger.info(
458-
"Server binding to %s with bearer token authentication enabled.",
455+
"Server binding to %s with Keycloak OIDC authentication.",
459456
host,
460457
)
461458
else:
@@ -465,34 +462,20 @@ def signal_handler(signum, frame):
465462
host,
466463
)
467464

468-
# Display API key info for client configuration
469-
if _api_key:
470-
masked = _api_key[:9] + "..." + _api_key[-4:]
471-
if _api_key_is_new:
472-
# First run: show full key so user can configure clients
473-
logger.warning(
474-
"NEW API key generated: %s — save this for your MCP client config",
475-
_api_key,
476-
)
477-
print(f"\n NEW API Key: {_api_key}")
478-
print(" Configure MCP clients with: Authorization: Bearer <key>")
479-
print(" Stored in: .env (THINGS_MCP_API_KEY)\n")
480-
else:
481-
logger.info(
482-
"API key active: %s — clients must send: Authorization: Bearer <key>",
483-
masked,
484-
)
485-
486-
# Display Keycloak JWT validation info
487-
from .settings import get_keycloak_issuer, get_keycloak_audience
465+
# Display Keycloak OIDC info
466+
from .settings import get_keycloak_issuer
488467

489468
kc_issuer = get_keycloak_issuer()
490-
kc_audience = get_keycloak_audience()
491-
if kc_issuer:
469+
settings = get_settings()
470+
if kc_issuer and settings.keycloak_client_secret:
492471
logger.info(
493-
"Keycloak JWT validation: issuer=%s audience=%s",
472+
"Keycloak OIDCProxy: issuer=%s client_id=%s",
494473
kc_issuer,
495-
kc_audience,
474+
settings.keycloak_client_id,
475+
)
476+
elif kc_issuer:
477+
logger.warning(
478+
"Keycloak issuer set but KEYCLOAK_CLIENT_SECRET missing — auth disabled"
496479
)
497480

498481
# Schema compatibility is now handled by ClientCompatibilityMiddleware.on_list_tools

src/things_mcp/server_core.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@
2121
from .logging_config import get_logger
2222
from .settings import (
2323
get_settings,
24-
get_api_key,
2524
get_keycloak_issuer,
26-
get_keycloak_audience,
2725
)
2826

2927
logger = get_logger(__name__)
@@ -426,7 +424,6 @@ def create_mcp_server() -> FastMCP:
426424
"""Create and configure the FastMCP server instance."""
427425
from .auth import create_auth
428426

429-
api_key = get_api_key()
430427
settings = get_settings()
431428

432429
# Public URL is used as the resource identifier in Protected Resource
@@ -436,22 +433,19 @@ def create_mcp_server() -> FastMCP:
436433
else:
437434
base_url = f"http://{settings.things_mcp_host}:{settings.things_mcp_port}"
438435

439-
keycloak_client_id = settings.keycloak_client_id
440436
keycloak_client_secret = settings.keycloak_client_secret
441437

442438
if not keycloak_client_secret:
443439
logger.warning(
444-
"KEYCLOAK_CLIENT_SECRET not set — OAuth/OIDC auth disabled. "
445-
"Only bearer-token auth will work."
440+
"KEYCLOAK_CLIENT_SECRET not set — auth disabled. "
441+
"Set it to enable OAuth via Keycloak."
446442
)
447443
auth = None
448444
else:
449445
auth = create_auth(
450-
api_key=api_key if api_key else None,
451446
base_url=base_url,
452447
keycloak_issuer=get_keycloak_issuer(),
453-
keycloak_audience=get_keycloak_audience(),
454-
keycloak_client_id=keycloak_client_id,
448+
keycloak_client_id=settings.keycloak_client_id,
455449
keycloak_client_secret=keycloak_client_secret,
456450
)
457451

src/things_mcp/settings.py

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -52,29 +52,19 @@ class Settings(BaseSettings):
5252
description="Things 3 authentication token (from Things > Settings > General > Enable Things URLs)",
5353
)
5454

55-
# Server authentication (inbound from MCP clients)
56-
things_mcp_api_key: str = Field(
57-
default="",
58-
description="API key for authenticating MCP clients. Auto-generated on first run if empty.",
59-
)
60-
6155
# Public URL (used as OAuth issuer — must be HTTPS for remote clients)
6256
things_mcp_public_url: str = Field(
6357
default="",
6458
description="Public HTTPS URL of this server (e.g. https://things.example.com). Required for OAuth/Claude.ai.",
6559
)
6660

67-
# Keycloak JWT validation
61+
# Keycloak OIDC (OIDCProxy for Claude.ai OAuth flow)
6862
keycloak_issuer: str = Field(
6963
default="https://auth.cdit-works.de/realms/cdit-mcp",
70-
description="Keycloak realm issuer URL for JWT validation.",
71-
)
72-
keycloak_audience: str = Field(
73-
default="mcp-things",
74-
description="Expected audience claim in Keycloak-issued JWTs.",
64+
description="Keycloak realm issuer URL.",
7565
)
7666
keycloak_client_id: str = Field(
77-
default="things-mcp",
67+
default="mcp-things",
7868
description="Pre-registered Keycloak client ID for OIDCProxy.",
7969
)
8070
keycloak_client_secret: str = Field(
@@ -111,15 +101,10 @@ def has_auth_token(self) -> bool:
111101
"""Check if an authentication token is configured."""
112102
return bool(self.things_auth_token)
113103

114-
@property
115-
def has_api_key(self) -> bool:
116-
"""Check if a server API key is configured."""
117-
return bool(self.things_mcp_api_key)
118-
119104
@property
120105
def has_keycloak_config(self) -> bool:
121-
"""Check if Keycloak JWT validation is configured."""
122-
return bool(self.keycloak_issuer)
106+
"""Check if Keycloak OIDC is configured."""
107+
return bool(self.keycloak_issuer and self.keycloak_client_secret)
123108

124109

125110
@lru_cache(maxsize=1)
@@ -180,21 +165,11 @@ def get_dashboard_url() -> str:
180165
return f"http://{host}:{port}/dashboard"
181166

182167

183-
def get_api_key() -> str:
184-
"""Get the server API key."""
185-
return get_settings().things_mcp_api_key
186-
187-
188168
def get_keycloak_issuer() -> str:
189169
"""Get the Keycloak realm issuer URL."""
190170
return get_settings().keycloak_issuer
191171

192172

193-
def get_keycloak_audience() -> str:
194-
"""Get the expected Keycloak JWT audience."""
195-
return get_settings().keycloak_audience
196-
197-
198173
def is_debug_enabled() -> bool:
199174
"""Check if debug logging is enabled.
200175

0 commit comments

Comments
 (0)