fix: replace Bearer JWTs with path-bound access tickets in MCP download tools

link_capture_download and get_symbol embedded a 10-min JWT in the
Authorization header of the curl command they return; LLM clients
retyping that command corrupted the long token — the same failure
class as the console WebSocket URLs fixed in the previous commit.

Generalize the ticket store (console_tickets.py -> access_tickets.py,
ConsoleTicketService -> AccessTicketService): a ticket now binds to
either a node's console endpoints (WebSocket, matched against route
path params) or one exact REST resource path (capture file, symbol
image). The two binding modes are isolated — a node-bound ticket
cannot authenticate a REST resource and vice versa.

get_user_from_token redeems path-bound tickets through the existing
token parameter / Bearer header, matched exactly against
request.url.path, then reuses the shared user lookup and token_version
revocation checks. Download URLs embed ?token=<ticket> and the curl
commands no longer carry a Bearer header.
This commit is contained in:
YueGuobin 2026-08-29 01:10:19 +08:00
parent 6d6b5351fa
commit d1edfbe5e8
No known key found for this signature in database
8 changed files with 293 additions and 88 deletions

View File

@ -439,7 +439,7 @@ sequenceDiagram
### Console WebSocket ### Console WebSocket
The `node_console` tool returns a WebSocket URL for connecting to a node's console. The URL includes a short-lived **console ticket** (10 min) — a short random string (`gns3t_…`, 22 chars) minted server-side and bound to that node's console endpoints. Tickets replaced the long JWT previously embedded here: LLM clients retyping the URL into shell commands reliably corrupted a ~200-char JWT, while a short ticket survives copying. Re-request the URL when the ticket expires (logging out also invalidates outstanding tickets). This endpoint is protocol-agnostic — it works for **telnet**, **ssh**, and **vnc** console types alike. The WebSocket simply proxies raw byte streams between the client and the compute node; protocol negotiation (e.g. SSH key exchange) happens on the compute side. The `node_console` tool returns a WebSocket URL for connecting to a node's console. The URL includes a short-lived **access ticket** (10 min) — a short random string (`gns3t_…`, 22 chars) minted server-side and bound to that node's console endpoints. `link_capture_download` uses the same kind of ticket bound to one exact resource path instead of embedding a Bearer JWT in the returned curl command. Tickets replaced the long JWTs previously embedded in these URLs/commands: LLM clients retyping them into shell commands reliably corrupted a ~200-char JWT, while a short ticket survives copying. Re-request the URL when the ticket expires (logging out also invalidates outstanding tickets). This endpoint is protocol-agnostic — it works for **telnet**, **ssh**, and **vnc** console types alike. The WebSocket simply proxies raw byte streams between the client and the compute node; protocol negotiation (e.g. SSH key exchange) happens on the compute side.
The WebSocket URL is constructed using the server's `_server_url()`, which resolves the host as follows: The WebSocket URL is constructed using the server's `_server_url()`, which resolves the host as follows:

View File

@ -53,8 +53,8 @@ from concurrent.futures import ThreadPoolExecutor
import hashlib import hashlib
import logging import logging
from gns3server.services import auth_service, console_ticket_service from gns3server.services import access_ticket_service
from gns3server.services.console_tickets import DEFAULT_TICKET_TTL from gns3server.services.access_tickets import DEFAULT_TICKET_TTL
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
@ -423,7 +423,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
# the ~200-char JWT previously embedded here (dropped header segment → # the ~200-char JWT previously embedded here (dropped header segment →
# "Missing 'alg' value in header" on the server). # "Missing 'alg' value in header" on the server).
username = gns3_ctx.get("jwt_username") username = gns3_ctx.get("jwt_username")
ticket = console_ticket_service.mint( ticket = access_ticket_service.mint(
username, username,
token_version=gns3_ctx.get("jwt_token_version", 0), token_version=gns3_ctx.get("jwt_token_version", 0),
project_id=project_id, project_id=project_id,
@ -859,34 +859,36 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
if not project_id: if not project_id:
return {"error": "project_id is required"} return {"error": "project_id is required"}
username = gns3_ctx.get("jwt_username") username = gns3_ctx.get("jwt_username")
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None token_version = gns3_ctx.get("jwt_token_version", 0)
def _download(link_id: str) -> dict[str, Any]:
# short-lived ticket bound to this exact download path — LLM clients
# retyping curl commands corrupted the long Bearer JWT this used to embed
path = f"/v3/projects/{project_id}/links/{link_id}/capture/file"
url = f"{gns3_ctx['server_url']}{path}"
ticket = access_ticket_service.mint(username, token_version=token_version, path=path) if username else None
if ticket:
url += f"?token={ticket}"
entry = {"link_id": link_id, "download_url": url}
if ticket:
entry["curl_command"] = f"curl -L -o capture_{link_id}.pcap '{url}'"
return entry
link_ids = params.get("link_ids") link_ids = params.get("link_ids")
if link_ids: if link_ids:
if not isinstance(link_ids, list): if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"} return {"error": "link_ids must be a list"}
results = [] results = [_download(lid) for lid in link_ids]
for lid in link_ids: return {"downloads": results, "count": len(results), "note": "Files are in pcap format. URLs include a 10-minute ticket."}
url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{lid}/capture/file"
entry = {"link_id": lid, "download_url": url}
if download_token:
cmd = f"curl -L -o capture_{lid}.pcap -H 'Authorization: Bearer {download_token}' '{url}'"
entry["curl_command"] = cmd
results.append(entry)
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. Links include a 10-minute token."}
link_id = params.get("link_id") link_id = params.get("link_id")
if not link_id: if not link_id:
return {"error": "link_id or link_ids is required"} return {"error": "link_id or link_ids is required"}
download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file" result = _download(link_id)
result = { result["note"] = "The file is in pcap format and can be analyzed with Wireshark or tcpdump."
"link_id": link_id, if "curl_command" in result:
"download_url": download_url, result["curl_command"] = f"curl -L -o capture.pcap '{result['download_url']}'"
"note": "The file is in pcap format and can be analyzed with Wireshark or tcpdump.", result["note"] += " The download URL includes a 10-minute ticket."
}
if download_token:
result["curl_command"] = f"curl -L -o capture.pcap -H 'Authorization: Bearer {download_token}' '{download_url}'"
result["note"] += " The download link includes a 10-minute token."
return result return result

View File

@ -23,7 +23,7 @@ from typing import Any
import logging import logging
from gns3server.services import auth_service from gns3server.services import access_ticket_service
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -52,18 +52,25 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
symbol_id = params.get("symbol_id") symbol_id = params.get("symbol_id")
if not symbol_id: if not symbol_id:
return {"error": "symbol_id is required"} return {"error": "symbol_id is required"}
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw" path = f"/v3/symbols/{symbol_id}/raw"
download_url = f"{gns3_ctx['server_url']}{path}"
username = gns3_ctx.get("jwt_username") username = gns3_ctx.get("jwt_username")
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None # short-lived ticket bound to this exact path — LLM clients retyping curl
# commands corrupted the long Bearer JWT this used to embed
ticket = access_ticket_service.mint(
username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path
) if username else None
if ticket:
download_url += f"?token={ticket}"
result = { result = {
"symbol_id": symbol_id, "symbol_id": symbol_id,
"download_url": download_url, "download_url": download_url,
"note": "Symbol files are SVG images.", "note": "Symbol files are SVG images.",
} }
if download_token: if ticket:
safe_name = symbol_id.replace(':', '').replace('/', '_') safe_name = symbol_id.replace(':', '').replace('/', '_')
result["curl_command"] = f"curl -L -o '{safe_name}.svg' -H 'Authorization: Bearer {download_token}' '{download_url}'" result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'"
result["note"] += " Download link includes a 10-minute token." result["note"] += " The download URL includes a 10-minute ticket."
return result return result

View File

@ -30,8 +30,8 @@ from gns3server.db.repositories.api_keys import ApiKeysRepository
from gns3server.db.repositories.users import UsersRepository from gns3server.db.repositories.users import UsersRepository
from gns3server.db.repositories.rbac import RbacRepository from gns3server.db.repositories.rbac import RbacRepository
from gns3server.schemas.controller.tokens import TokenData from gns3server.schemas.controller.tokens import TokenData
from gns3server.services import auth_service, console_ticket_service from gns3server.services import auth_service, access_ticket_service
from gns3server.services.console_tickets import TICKET_PREFIX from gns3server.services.access_tickets import TICKET_PREFIX
from .database import get_repository from .database import get_repository
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -50,6 +50,7 @@ def _reject_refresh_token(token_data) -> None:
async def get_user_from_token( async def get_user_from_token(
request: Request,
bearer_token: str = Depends(oauth2_scheme), bearer_token: str = Depends(oauth2_scheme),
user_repo: UsersRepository = Depends(get_repository(UsersRepository)), user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
@ -68,6 +69,38 @@ async def get_user_from_token(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if token.startswith(TICKET_PREFIX):
# Access tickets ("gns3t_…"): short-lived credentials bound to one
# exact resource path, minted by the MCP download tools (capture
# files, symbols). redeem_for_path() confines the ticket to that
# path, so it cannot be replayed against any other resource.
ticket = access_ticket_service.redeem_for_path(token, request.url.path)
if ticket is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired access ticket",
headers={"WWW-Authenticate": "Bearer"},
)
token_data = TokenData(
username=ticket.username,
token_version=ticket.token_version,
token_use="access",
)
user = await user_repo.get_user_by_username(token_data.username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if token_data.token_version != user.token_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token has been revoked for '{token_data.username}'",
headers={"WWW-Authenticate": "Bearer"},
)
return user
# API Key authentication — format: gns3_<api_key_id>_<random_secret> # API Key authentication — format: gns3_<api_key_id>_<random_secret>
# Direct lookup by UUID avoids O(n) scan of all keys. # Direct lookup by UUID avoids O(n) scan of all keys.
if token.startswith("gns3_"): if token.startswith("gns3_"):
@ -152,12 +185,12 @@ async def get_current_active_user_from_websocket(
try: try:
if token.startswith(TICKET_PREFIX): if token.startswith(TICKET_PREFIX):
# Console tickets ("gns3t_…"): short-lived credentials bound to one # Node-bound access tickets ("gns3t_…"): short-lived credentials
# node's console endpoints, minted by the node_console MCP tool. # for one node's console endpoints, minted by the node_console
# redeem() confines them to the route matching their binding, so # MCP tool. redeem() confines them to the route matching their
# they cannot authenticate the notification/wireshark sockets that # binding, so they cannot authenticate the notification/wireshark
# share this dependency. # sockets that share this dependency.
ticket = console_ticket_service.redeem(token, websocket.path_params) ticket = access_ticket_service.redeem(token, websocket.path_params)
if ticket is None: if ticket is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from .authentication import AuthService from .authentication import AuthService
from .console_tickets import ConsoleTicketService from .access_tickets import AccessTicketService
auth_service = AuthService() auth_service = AuthService()
console_ticket_service = ConsoleTicketService() access_ticket_service = AccessTicketService()

View File

@ -15,20 +15,27 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
Short-lived console tickets. Short-lived access tickets.
A console ticket is a short random string ("gns3t_" + 16 urlsafe chars) that An access ticket is a short random string ("gns3t_" + 16 urlsafe chars) that
replaces the long JWT previously embedded in console WebSocket URLs returned replaces the long JWTs previously embedded in URLs and curl commands returned
to LLM clients, which reliably corrupted the ~200-char JWT when retyping it to LLM clients, which reliably corrupted the ~200-char JWT when retyping it
into a shell command. The ticket carries no information itself — the server into a shell command. The ticket carries no information itself — the server
remembers what it maps to, which is what makes a 96-bit random string a remembers what it maps to, which is what makes a 96-bit random string a
sufficient credential. sufficient credential.
Tickets are bound to a single node's console endpoints (console/ws and A ticket is bound to exactly one target, in one of two modes:
console/vnc share the same binding) and are multi-use within their TTL, so
console clients that reconnect after a drop keep working. Redeeming also - node binding (project_id + node_id): redeemable on that node's console
re-checks the minting user's token_version, so logging out invalidates WebSocket endpoints (console/ws and console/vnc share the binding),
outstanding tickets just like it invalidates JWTs. validated against the route's path parameters.
- path binding (exact resource path): redeemable on one REST resource path
(e.g. a capture file download or a symbol image), validated against
request.url.path.
Tickets are multi-use within their TTL, so clients that reconnect keep
working. Redemption re-checks the minting user's token_version, so logging
out invalidates outstanding tickets just like it invalidates JWTs.
""" """
import logging import logging
@ -45,34 +52,36 @@ DEFAULT_TICKET_TTL = 600 # seconds
@dataclass @dataclass
class ConsoleTicket: class AccessTicket:
username: str username: str
token_version: int token_version: int
project_id: str project_id: Optional[str] = None # node binding: console WebSocket endpoints
node_id: str node_id: Optional[str] = None
expires_at: float # time.monotonic() based path: Optional[str] = None # path binding: one exact REST resource path
expires_at: float = 0.0 # time.monotonic() based
class ConsoleTicketService: class AccessTicketService:
""" """
In-memory store for console tickets. In-memory store for access tickets.
Single-process asyncio app: minting (MCP tool handlers, via worker Single-process asyncio app: minting (MCP tool handlers, via worker
threads) and redemption (WS auth dependency, event loop) share one dict. threads) and redemption (auth dependencies, event loop) share one dict.
dict get/set/del are atomic under the GIL and keys are random, so no dict get/set/del are atomic under the GIL and keys are random, so no
locking is needed. Tickets vanish on restart — clients just request a locking is needed. Tickets vanish on restart — clients just request a
new one. new one.
""" """
def __init__(self) -> None: def __init__(self) -> None:
self._tickets: dict[str, ConsoleTicket] = {} self._tickets: dict[str, AccessTicket] = {}
def mint( def mint(
self, self,
username: str, username: str,
token_version: int, token_version: int,
project_id: str, project_id: Optional[str] = None,
node_id: str, node_id: Optional[str] = None,
path: Optional[str] = None,
ttl: int = DEFAULT_TICKET_TTL, ttl: int = DEFAULT_TICKET_TTL,
) -> str: ) -> str:
# 12 random bytes → 16 urlsafe chars (~96 bits); comfortably # 12 random bytes → 16 urlsafe chars (~96 bits); comfortably
@ -80,33 +89,58 @@ class ConsoleTicketService:
# LLM clients copy it without corruption. # LLM clients copy it without corruption.
ticket = TICKET_PREFIX + secrets.token_urlsafe(12) ticket = TICKET_PREFIX + secrets.token_urlsafe(12)
self._sweep_expired() self._sweep_expired()
self._tickets[ticket] = ConsoleTicket( self._tickets[ticket] = AccessTicket(
username=username, username=username,
token_version=token_version, token_version=token_version,
project_id=project_id, project_id=project_id,
node_id=node_id, node_id=node_id,
path=path,
expires_at=time.monotonic() + ttl, expires_at=time.monotonic() + ttl,
) )
return ticket return ticket
def redeem(self, ticket: str, path_params: dict) -> Optional[ConsoleTicket]: def redeem(self, ticket: str, path_params: dict) -> Optional[AccessTicket]:
""" """
Validate a ticket against the route it is being used on. Validate a node-bound ticket against a WebSocket route.
Returns the ticket record if valid, None otherwise. The route's path Returns the ticket record if valid, None otherwise. The route's path
parameters must match the ticket's binding, which confines a ticket parameters must match the ticket's binding, which confines a ticket
to the node's console endpoints — routes without a node_id path to the node's console endpoints — routes without a node_id path
parameter (notifications, web wireshark, …) always fail the binding. parameter (notifications, web wireshark, …) always fail the binding,
and so do path-bound (REST) tickets.
""" """
record = self._get_valid(ticket)
if record is None:
return None
if record.node_id is None or record.path is not None:
return None
if path_params.get("project_id") != record.project_id or path_params.get("node_id") != record.node_id:
return None
return record
def redeem_for_path(self, ticket: str, path: str) -> Optional[AccessTicket]:
"""
Validate a path-bound ticket against a REST resource path.
The path must match exactly — a ticket minted for one capture file
download cannot be replayed against any other resource.
"""
record = self._get_valid(ticket)
if record is None:
return None
if record.path is None or record.path != path:
return None
return record
def _get_valid(self, ticket: str) -> Optional[AccessTicket]:
record = self._tickets.get(ticket) record = self._tickets.get(ticket)
if record is None: if record is None:
return None return None
if time.monotonic() >= record.expires_at: if time.monotonic() >= record.expires_at:
self._tickets.pop(ticket, None) self._tickets.pop(ticket, None)
return None return None
if path_params.get("project_id") != record.project_id or path_params.get("node_id") != record.node_id:
return None
return record return record
def _sweep_expired(self) -> None: def _sweep_expired(self) -> None:

View File

@ -280,7 +280,7 @@ class TestNode:
def test_console(self, ctx): def test_console(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
from gns3server.services import console_ticket_service from gns3server.services import access_ticket_service
with patch(f"{AH}._get_connector") as m: with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"console_url": "ws://host/console"}) m.return_value = _mock_conn({"console_url": "ws://host/console"})
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx) result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx)
@ -291,7 +291,7 @@ class TestNode:
assert ticket in result["command"] assert ticket in result["command"]
assert result["token_ttl_seconds"] == 600 assert result["token_ttl_seconds"] == 600
assert len(result["token_sha256_prefix"]) == 8 assert len(result["token_sha256_prefix"]) == 8
redeemed = console_ticket_service.redeem(ticket, {"project_id": "p1", "node_id": "n1"}) redeemed = access_ticket_service.redeem(ticket, {"project_id": "p1", "node_id": "n1"})
assert redeemed is not None and redeemed.username == "admin" assert redeemed is not None and redeemed.username == "admin"
assert "vnc_url" not in result # console_type is not vnc assert "vnc_url" not in result # console_type is not vnc
@ -462,6 +462,56 @@ class TestLink:
}, ctx) }, ctx)
assert result["suspend"] is True assert result["suspend"] is True
def test_download_capture_file(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import download_capture_file_handler
from gns3server.services import access_ticket_service
result = download_capture_file_handler({"project_id": "p1", "link_id": "l1"}, ctx)
# a short path-bound ticket replaces the long Bearer JWT in the URL
assert "?token=gns3t_" in result["download_url"]
assert "Bearer" not in result["curl_command"]
path = f"/v3/projects/p1/links/l1/capture/file"
ticket = result["download_url"].split("token=")[1]
assert result["curl_command"] == f"curl -L -o capture.pcap '{result['download_url']}'"
redeemed = access_ticket_service.redeem_for_path(ticket, path)
assert redeemed is not None and redeemed.username == "admin"
def test_download_capture_file_batch(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import download_capture_file_handler
from gns3server.services import access_ticket_service
result = download_capture_file_handler({"project_id": "p1", "link_ids": ["l1", "l2"]}, ctx)
assert result["count"] == 2
# each link gets its own ticket bound to its own download path
tickets = [entry["download_url"].split("token=")[1] for entry in result["downloads"]]
assert tickets[0] != tickets[1]
for lid, ticket in zip(["l1", "l2"], tickets):
redeemed = access_ticket_service.redeem_for_path(ticket, f"/v3/projects/p1/links/{lid}/capture/file")
assert redeemed is not None
# ── Symbol ──────────────────────────────────────────────────────────────
class TestSymbol:
mod = "symbols"
def test_get_symbol_download(self, ctx):
from gns3server.agent.mcp.symbols import get_symbol_handler
from gns3server.services import access_ticket_service
result = get_symbol_handler({"symbol_id": "router.svg"}, ctx)
assert "?token=gns3t_" in result["download_url"]
assert "Bearer" not in result["curl_command"]
ticket = result["download_url"].split("token=")[1]
redeemed = access_ticket_service.redeem_for_path(ticket, "/v3/symbols/router.svg/raw")
assert redeemed is not None and redeemed.username == "admin"
def test_get_symbol_without_username_omits_token(self, ctx):
from gns3server.agent.mcp.symbols import get_symbol_handler
unauthenticated_ctx = {k: v for k, v in ctx.items() if k != "jwt_username"}
result = get_symbol_handler({"symbol_id": "router.svg"}, unauthenticated_ctx)
assert "token=" not in result["download_url"]
assert "curl_command" not in result
# ── Appliance ─────────────────────────────────────────────────────────── # ── Appliance ───────────────────────────────────────────────────────────

View File

@ -20,7 +20,7 @@ from typing import List
import aiohttp import aiohttp
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI, status
from httpx import AsyncClient from httpx import AsyncClient
from httpx_ws import aconnect_ws from httpx_ws import aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport from httpx_ws.transport import ASGIWebSocketTransport
@ -31,33 +31,33 @@ from gns3server.controller import Controller
from gns3server.controller.compute import Compute from gns3server.controller.compute import Compute
from gns3server.controller.node import Node from gns3server.controller.node import Node
from gns3server.controller.project import Project from gns3server.controller.project import Project
from gns3server.services.console_tickets import ( from gns3server.services import access_ticket_service
ConsoleTicketService, from gns3server.services.access_tickets import (
AccessTicketService,
DEFAULT_TICKET_TTL, DEFAULT_TICKET_TTL,
TICKET_PREFIX, TICKET_PREFIX,
) )
from gns3server.services import console_ticket_service
from gns3server.utils.http_client import HTTPClient from gns3server.utils.http_client import HTTPClient
from tests.api.routes.controller.test_nodes import FakeComputeConsoleWebSocket from tests.api.routes.controller.test_nodes import FakeComputeConsoleWebSocket
class TestConsoleTicketService: class TestAccessTicketService:
"""Unit tests for the in-memory ticket store (fresh instances, no app).""" """Unit tests for the in-memory ticket store (fresh instances, no app)."""
def test_mint_format(self) -> None: def test_mint_format(self) -> None:
service = ConsoleTicketService() service = AccessTicketService()
ticket = service.mint("admin", 1, "p1", "n1") ticket = service.mint("admin", 1, project_id="p1", node_id="n1")
assert ticket.startswith(TICKET_PREFIX) assert ticket.startswith(TICKET_PREFIX)
# 12 random bytes -> 16 urlsafe chars; shell-safe alphabet (no quoting hazards) # 12 random bytes -> 16 urlsafe chars; shell-safe alphabet (no quoting hazards)
assert len(ticket) == len(TICKET_PREFIX) + 16 assert len(ticket) == len(TICKET_PREFIX) + 16
assert "." not in ticket # never confusable with a JWT assert "." not in ticket # never confusable with a JWT
assert service.mint("admin", 1, "p1", "n1") != ticket assert service.mint("admin", 1, project_id="p1", node_id="n1") != ticket
def test_redeem_valid_ticket_is_multi_use(self) -> None: def test_redeem_valid_ticket_is_multi_use(self) -> None:
service = ConsoleTicketService() service = AccessTicketService()
ticket = service.mint("admin", 7, "p1", "n1") ticket = service.mint("admin", 7, project_id="p1", node_id="n1")
path_params = {"project_id": "p1", "node_id": "n1"} path_params = {"project_id": "p1", "node_id": "n1"}
first = service.redeem(ticket, path_params) first = service.redeem(ticket, path_params)
second = service.redeem(ticket, path_params) # console clients reconnect, tickets stay usable second = service.redeem(ticket, path_params) # console clients reconnect, tickets stay usable
@ -67,8 +67,8 @@ class TestConsoleTicketService:
def test_redeem_rejects_wrong_binding(self) -> None: def test_redeem_rejects_wrong_binding(self) -> None:
service = ConsoleTicketService() service = AccessTicketService()
ticket = service.mint("admin", 0, "p1", "n1") ticket = service.mint("admin", 0, project_id="p1", node_id="n1")
assert service.redeem(ticket, {"project_id": "p1", "node_id": "n2"}) is None assert service.redeem(ticket, {"project_id": "p1", "node_id": "n2"}) is None
assert service.redeem(ticket, {"project_id": "p2", "node_id": "n1"}) is None assert service.redeem(ticket, {"project_id": "p2", "node_id": "n1"}) is None
# routes without a node_id path parameter (notifications, wireshark…) never accept a ticket # routes without a node_id path parameter (notifications, wireshark…) never accept a ticket
@ -77,26 +77,54 @@ class TestConsoleTicketService:
def test_redeem_rejects_unknown_ticket(self) -> None: def test_redeem_rejects_unknown_ticket(self) -> None:
service = ConsoleTicketService() service = AccessTicketService()
assert service.redeem(TICKET_PREFIX + "doesnotexist", {"project_id": "p1", "node_id": "n1"}) is None assert service.redeem(TICKET_PREFIX + "doesnotexist", {"project_id": "p1", "node_id": "n1"}) is None
def test_expired_ticket_is_rejected_and_removed(self) -> None: def test_expired_ticket_is_rejected_and_removed(self) -> None:
service = ConsoleTicketService() service = AccessTicketService()
ticket = service.mint("admin", 0, "p1", "n1", ttl=0) ticket = service.mint("admin", 0, project_id="p1", node_id="n1", ttl=0)
assert service.redeem(ticket, {"project_id": "p1", "node_id": "n1"}) is None assert service.redeem(ticket, {"project_id": "p1", "node_id": "n1"}) is None
assert ticket not in service._tickets assert ticket not in service._tickets
def test_mint_sweeps_expired_entries(self) -> None: def test_mint_sweeps_expired_entries(self) -> None:
service = ConsoleTicketService() service = AccessTicketService()
stale = service.mint("admin", 0, "p1", "n1", ttl=0) stale = service.mint("admin", 0, project_id="p1", node_id="n1", ttl=0)
fresh = service.mint("admin", 0, "p1", "n2", ttl=DEFAULT_TICKET_TTL) fresh = service.mint("admin", 0, project_id="p1", node_id="n2", ttl=DEFAULT_TICKET_TTL)
assert stale not in service._tickets assert stale not in service._tickets
assert fresh in service._tickets assert fresh in service._tickets
def test_redeem_for_path_valid_ticket_is_multi_use(self) -> None:
class TestConsoleTicketWebSocketAuth: service = AccessTicketService()
ticket = service.mint("admin", 3, path="/v3/projects/p1/links/l1/capture/file")
first = service.redeem_for_path(ticket, "/v3/projects/p1/links/l1/capture/file")
second = service.redeem_for_path(ticket, "/v3/projects/p1/links/l1/capture/file")
assert first is not None and second is not None
assert first.username == "admin"
assert first.token_version == 3
def test_redeem_for_path_rejects_other_path(self) -> None:
service = AccessTicketService()
ticket = service.mint("admin", 0, path="/v3/projects/p1/links/l1/capture/file")
assert service.redeem_for_path(ticket, "/v3/projects/p1/links/l2/capture/file") is None
assert service.redeem_for_path(ticket, "/v3/projects/p2/links/l1/capture/file") is None
assert service.redeem_for_path(ticket, "/v3/symbols/router.svg/raw") is None
def test_binding_modes_are_isolated(self) -> None:
service = AccessTicketService()
# a node-bound ticket must not authenticate REST resources…
ws_ticket = service.mint("admin", 0, project_id="p1", node_id="n1")
assert service.redeem_for_path(ws_ticket, "/v3/projects/p1/nodes/n1/console/ws") is None
# …and a path-bound ticket must not authenticate WebSocket routes
rest_ticket = service.mint("admin", 0, path="/v3/projects/p1/links/l1/capture/file")
assert service.redeem(rest_ticket, {"project_id": "p1", "node_id": "n1"}) is None
class TestAccessTicketWebSocketAuth:
""" """
Drive the console WebSocket auth dependency end-to-end through the real Drive the console WebSocket auth dependency end-to-end through the real
routes (the shared service singleton is the one the dependency consults). routes (the shared service singleton is the one the dependency consults).
@ -158,7 +186,7 @@ class TestConsoleTicketWebSocketAuth:
monkeypatch monkeypatch
) -> None: ) -> None:
# admin is the seeded superadmin, so the RBAC privilege check is skipped # admin is the seeded superadmin, so the RBAC privilege check is skipped
ticket = console_ticket_service.mint("admin", 0, project.id, node.id) ticket = access_ticket_service.mint("admin", 0, project_id=project.id, node_id=node.id)
self._forward_compute_ws(monkeypatch, [ self._forward_compute_ws(monkeypatch, [
aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "device output", None), aiohttp.WSMessage(aiohttp.WSMsgType.TEXT, "device output", None),
]) ])
@ -181,7 +209,7 @@ class TestConsoleTicketWebSocketAuth:
) -> None: ) -> None:
other_node = "00000000-0000-0000-0000-000000000000" other_node = "00000000-0000-0000-0000-000000000000"
ticket = console_ticket_service.mint("admin", 0, project.id, other_node) ticket = access_ticket_service.mint("admin", 0, project_id=project.id, node_id=other_node)
async with self._ws_client(app, base_client) as client: async with self._ws_client(app, base_client) as client:
async with aconnect_ws( async with aconnect_ws(
f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", f"/v3/projects/{project.id}/nodes/{node.id}/console/ws",
@ -203,7 +231,7 @@ class TestConsoleTicketWebSocketAuth:
node: Node node: Node
) -> None: ) -> None:
# logging out bumps the user's token_version: outstanding tickets must die with it # logging out bumps the user's token_version: outstanding tickets must die with it
ticket = console_ticket_service.mint("admin", 999, project.id, node.id) ticket = access_ticket_service.mint("admin", 999, project_id=project.id, node_id=node.id)
async with self._ws_client(app, base_client) as client: async with self._ws_client(app, base_client) as client:
async with aconnect_ws( async with aconnect_ws(
f"/v3/projects/{project.id}/nodes/{node.id}/console/ws", f"/v3/projects/{project.id}/nodes/{node.id}/console/ws",
@ -220,8 +248,59 @@ class TestConsoleTicketWebSocketAuth:
) -> None: ) -> None:
# the controller notification stream shares the WS auth dependency but has # the controller notification stream shares the WS auth dependency but has
# no node binding: a console ticket must not authenticate it # no node binding: a console ticket must not authenticate it
ticket = console_ticket_service.mint("admin", 0, "p1", "n1") ticket = access_ticket_service.mint("admin", 0, project_id="p1", node_id="n1")
async with self._ws_client(app, base_client) as client: async with self._ws_client(app, base_client) as client:
async with aconnect_ws("/v3/notifications/ws", client, params={"token": ticket}) as ws: async with aconnect_ws("/v3/notifications/ws", client, params={"token": ticket}) as ws:
notification = await ws.receive_json() notification = await ws.receive_json()
assert "Invalid or expired console ticket" in notification["event"]["message"] assert "Invalid or expired console ticket" in notification["event"]["message"]
class TestAccessTicketRestAuth:
"""
Drive the REST auth dependency (get_user_from_token) end-to-end: a
path-bound ticket authenticates exactly one resource path.
"""
pytestmark = pytest.mark.asyncio
async def test_rest_accepts_path_bound_ticket(
self,
app: FastAPI,
base_client: AsyncClient
) -> None:
# base_client carries no Authorization header, so the ?token= parameter is used
ticket = access_ticket_service.mint("admin", 0, path="/v3/projects")
response = await base_client.get("/v3/projects", params={"token": ticket})
assert response.status_code == status.HTTP_200_OK
async def test_rest_rejects_ticket_bound_to_other_path(
self,
app: FastAPI,
base_client: AsyncClient
) -> None:
ticket = access_ticket_service.mint("admin", 0, path="/v3/symbols/router.svg/raw")
response = await base_client.get("/v3/projects", params={"token": ticket})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "Invalid or expired access ticket" in response.text
async def test_rest_rejects_node_bound_ticket(
self,
app: FastAPI,
base_client: AsyncClient
) -> None:
# node-bound (console) tickets must not authenticate REST resources
ticket = access_ticket_service.mint("admin", 0, project_id="p1", node_id="n1")
response = await base_client.get("/v3/projects", params={"token": ticket})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_rest_rejects_ticket_with_stale_token_version(
self,
app: FastAPI,
base_client: AsyncClient
) -> None:
# logging out bumps the user's token_version: outstanding tickets must die with it
ticket = access_ticket_service.mint("admin", 999, path="/v3/projects")
response = await base_client.get("/v3/projects", params={"token": ticket})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "revoked" in response.text