Skip to content

Commit 36141b9

Browse files
committed
refactor: simplify token resolution — remove legacy paths and JWT validation
- Remove legacy credential file paths (_creds_candidates) - Remove JWT validation from _load_rnk_token (issuer/expiry without signature verification is security theater; the data API validates) - Replace bare except Exception with specific types (bandit B110/B112) - Add tests for _load_rnk_token and _resolve_token
1 parent 5f0015d commit 36141b9

3 files changed

Lines changed: 101 additions & 49 deletions

File tree

bases/renku_data_services/mcp_api/main.py

Lines changed: 10 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,16 @@
1313
Token resolution order
1414
----------------------
1515
1. RENKU_ACCESS_TOKEN, RENKU_TOKEN, or RENKU_CLI_ACCESS_TOKEN env var.
16-
2. Legacy credential files (~/.config/renku-agent-skill/credentials.json, etc.).
17-
3. Official rnk CLI token file (platform-specific path, validated for issuer + expiry).
16+
2. rnk CLI token file (platform-specific path, token forwarded as-is to the data API).
1817
"""
1918

2019
from __future__ import annotations
2120

2221
import asyncio
23-
import base64
2422
import json
2523
import logging
2624
import os
2725
import sys
28-
import time
2926
from collections.abc import Callable
3027
from pathlib import Path
3128
from typing import Any
@@ -45,18 +42,6 @@ def _base_url() -> str:
4542
return os.environ.get("RENKU_BASE_URL", "https://renkulab.io").rstrip("/")
4643

4744

48-
def _creds_candidates() -> list[Path]:
49-
"""Legacy credential file paths to search."""
50-
candidates: list[Path] = []
51-
if d := os.environ.get("RENKU_CONFIG_DIR"):
52-
candidates.append(Path(d) / "credentials.json")
53-
home = Path.home()
54-
candidates += [
55-
home / ".config" / "renku-agent-skill" / "credentials.json",
56-
home / ".pi" / "renku-config" / "credentials.json",
57-
]
58-
return candidates
59-
6045

6146
def _rnk_token_paths() -> list[Path]:
6247
"""Paths where the official rnk CLI stores its token file (platform-specific)."""
@@ -72,62 +57,40 @@ def _rnk_token_paths() -> list[Path]:
7257

7358

7459
def _load_rnk_token() -> str | None:
75-
"""Read an access token from the rnk CLI token file, validating issuer and expiry."""
76-
expected_issuer = _base_url() + "/auth/realms/Renku"
60+
"""Read an access token from the rnk CLI token file.
61+
62+
No JWT validation is performed here — the token is forwarded to the Renku
63+
data API which is the authoritative validator (signature, issuer, expiry).
64+
"""
7765
for path in _rnk_token_paths():
7866
if not path.exists():
7967
continue
8068
try:
8169
data = json.loads(path.read_text())
8270
response = data.get("response") or data
8371
access = response.get("access_token")
84-
if not access:
85-
continue
86-
try:
87-
payload_part = access.split(".")[1]
88-
payload_part += "=" * (-len(payload_part) % 4)
89-
payload: dict[str, Any] = json.loads(base64.urlsafe_b64decode(payload_part.encode()))
90-
if payload.get("iss") != expected_issuer:
91-
continue # token is for a different deployment
92-
if payload.get("exp") and time.time() > int(payload["exp"]) - 60:
93-
continue # token is expired or expires in < 60 s
94-
except (ValueError, KeyError, IndexError):
95-
# JWT decode failed — accept the token anyway and let Keycloak validate it.
96-
pass
97-
return access
72+
if access:
73+
return access
9874
except (OSError, json.JSONDecodeError, KeyError):
9975
continue
10076
return None
10177

10278

10379
def _resolve_token() -> str:
10480
"""Return the best available token, or raise with a helpful message."""
105-
# 1. Environment variables (highest priority)
81+
# 1. Environment variables (explicit override)
10682
for var in ("RENKU_ACCESS_TOKEN", "RENKU_TOKEN", "RENKU_CLI_ACCESS_TOKEN"):
10783
if t := os.environ.get(var):
10884
return t
10985

110-
# 2. Legacy credential files
111-
checked: list[str] = []
112-
for f in _creds_candidates():
113-
checked.append(str(f))
114-
if f.exists():
115-
try:
116-
entry = json.loads(f.read_text()).get(_base_url(), {})
117-
if t := entry.get("access_token") or entry.get("token"):
118-
return t
119-
except (OSError, json.JSONDecodeError, KeyError, AttributeError):
120-
pass # malformed or unreadable credential file — try next source
121-
122-
# 3. Official rnk CLI token file
86+
# 2. rnk CLI token file
12387
if t := _load_rnk_token():
12488
return t
12589

12690
rnk_paths = [str(p) for p in _rnk_token_paths()]
12791
raise RuntimeError(
12892
f"Not authenticated for {_base_url()}.\n"
12993
f"Run: rnk login\n"
130-
f"Credentials searched in: {', '.join(checked)}\n"
13194
f"rnk token paths searched: {', '.join(rnk_paths)}\n"
13295
f"Or set RENKU_ACCESS_TOKEN in the MCP server environment config."
13396
)

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,9 @@ disable_error_code = ["no-untyped-def", "var-annotated", "import-untyped"]
295295
# would require casts on every tool, adding noise without safety benefit.
296296
module = ["renku_data_services.mcp_api.*"]
297297
warn_return_any = false
298-
disable_error_code = ["no-untyped-def"]
298+
# misc covers "untyped decorator makes function untyped" from @mcp.tool() —
299+
# the mcp package doesn't ship type stubs.
300+
disable_error_code = ["no-untyped-def", "misc"]
299301

300302
[[tool.mypy.overrides]]
301303
module = ["mcp.*", "fastmcp.*", "uvicorn.*", "starlette.*"]

test/bases/renku_data_services/mcp_api/test_mcp_server.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
from __future__ import annotations
44

5+
import json
56
from typing import Any
67

78
import pytest
89

910
from renku_data_services.mcp_api.dependencies import MCPDependencies
10-
from renku_data_services.mcp_api.main import _authorization_server_doc, _protected_resource_doc
11+
from renku_data_services.mcp_api.main import (
12+
_authorization_server_doc,
13+
_load_rnk_token,
14+
_protected_resource_doc,
15+
_resolve_token,
16+
_rnk_token_paths,
17+
)
1118
from renku_data_services.mcp_api.server import (
1219
_admin_cache,
1320
_is_stale_session,
@@ -131,6 +138,86 @@ async def test_authorization_server_doc_no_keycloak_url():
131138
assert status == 503
132139

133140

141+
# ------------------------------------------------------------------ #
142+
# Token resolution — _load_rnk_token and _resolve_token #
143+
# ------------------------------------------------------------------ #
144+
145+
146+
def test_load_rnk_token_finds_token_in_response_key(tmp_path, monkeypatch):
147+
"""_load_rnk_token reads the token from the 'response' wrapper used by rnk."""
148+
token_file = tmp_path / "token.json"
149+
token_file.write_text(json.dumps({"response": {"access_token": "my-token"}}))
150+
151+
monkeypatch.setattr("renku_data_services.mcp_api.main._rnk_token_paths", lambda: [token_file])
152+
assert _load_rnk_token() == "my-token"
153+
154+
155+
def test_load_rnk_token_finds_token_at_root(tmp_path, monkeypatch):
156+
"""_load_rnk_token also reads the token when there's no 'response' wrapper."""
157+
token_file = tmp_path / "token.json"
158+
token_file.write_text(json.dumps({"access_token": "my-token"}))
159+
160+
monkeypatch.setattr("renku_data_services.mcp_api.main._rnk_token_paths", lambda: [token_file])
161+
assert _load_rnk_token() == "my-token"
162+
163+
164+
def test_load_rnk_token_forwards_any_token_value(tmp_path, monkeypatch):
165+
"""_load_rnk_token forwards tokens as-is without JWT validation — the API validates."""
166+
token_file = tmp_path / "token.json"
167+
token_file.write_text(json.dumps({"access_token": "opaque-or-expired-or-wrong-issuer"}))
168+
169+
monkeypatch.setattr("renku_data_services.mcp_api.main._rnk_token_paths", lambda: [token_file])
170+
assert _load_rnk_token() == "opaque-or-expired-or-wrong-issuer"
171+
172+
173+
def test_load_rnk_token_no_file(monkeypatch):
174+
"""_load_rnk_token returns None when no token file exists."""
175+
monkeypatch.setattr("renku_data_services.mcp_api.main._rnk_token_paths", lambda: [])
176+
assert _load_rnk_token() is None
177+
178+
179+
def test_resolve_token_prefers_env_var(tmp_path, monkeypatch):
180+
"""_resolve_token returns the env var even when an rnk token file exists."""
181+
monkeypatch.setenv("RENKU_ACCESS_TOKEN", "env-token")
182+
monkeypatch.setattr("renku_data_services.mcp_api.main._rnk_token_paths", lambda: [])
183+
assert _resolve_token() == "env-token"
184+
185+
186+
def test_resolve_token_falls_back_to_rnk(tmp_path, monkeypatch):
187+
"""_resolve_token falls back to the rnk file when no env var is set."""
188+
base_url = "https://renkulab.io"
189+
monkeypatch.setenv("RENKU_BASE_URL", base_url)
190+
monkeypatch.delenv("RENKU_ACCESS_TOKEN", raising=False)
191+
monkeypatch.delenv("RENKU_TOKEN", raising=False)
192+
monkeypatch.delenv("RENKU_CLI_ACCESS_TOKEN", raising=False)
193+
194+
token_file = tmp_path / "token.json"
195+
token_file.write_text(json.dumps({"access_token": "rnk-token"}))
196+
197+
monkeypatch.setattr("renku_data_services.mcp_api.main._rnk_token_paths", lambda: [token_file])
198+
assert _resolve_token() == "rnk-token"
199+
200+
201+
def test_resolve_token_raises_when_nothing_found(monkeypatch):
202+
"""_resolve_token raises RuntimeError with a helpful message when no token is available."""
203+
monkeypatch.delenv("RENKU_ACCESS_TOKEN", raising=False)
204+
monkeypatch.delenv("RENKU_TOKEN", raising=False)
205+
monkeypatch.delenv("RENKU_CLI_ACCESS_TOKEN", raising=False)
206+
monkeypatch.setattr("renku_data_services.mcp_api.main._rnk_token_paths", lambda: [])
207+
208+
with pytest.raises(RuntimeError, match="rnk login"):
209+
_resolve_token()
210+
211+
212+
def test_rnk_token_paths_uses_xdg(monkeypatch):
213+
"""_rnk_token_paths respects XDG_DATA_HOME."""
214+
monkeypatch.setenv("XDG_DATA_HOME", "/custom/xdg")
215+
monkeypatch.delenv("APPDATA", raising=False)
216+
217+
paths = _rnk_token_paths()
218+
assert any("/custom/xdg" in str(p) for p in paths)
219+
220+
134221
# ------------------------------------------------------------------ #
135222
# MCPDependencies.api — test via pytest-httpx #
136223
# ------------------------------------------------------------------ #

0 commit comments

Comments
 (0)