diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 4943986..3a3c29e 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -91,6 +91,16 @@ server: # sure you trust every client you paste one into). The # BEACONMCP_NAMED_TOKEN_TTL env var overrides this. # named_token_ttl: 2592000 + # Idle lifetime (seconds) of OAuth refresh tokens handed to + # authorization_code clients (claude.ai, ChatGPT, Le Chat...). Each + # refresh rotates the token and restarts this clock; replaying a + # rotated-out token revokes the whole chain. No sign-in outlives 24 h + # though: every refresh token expires 24 h after the authorization, + # whatever this says, so clients re-enter their TOTP once a day. Default + # and maximum 86400 (24 h); 0 (never) is refused. A smaller value only + # adds an idle timeout inside the day. The BEACONMCP_REFRESH_TOKEN_TTL + # env var overrides this. + # refresh_token_ttl: 86400 # -------- Proxmox capability (optional) ------------------------------------ # Delete this section if you have no Proxmox cluster. Tools starting with diff --git a/docs/clients.md b/docs/clients.md index 5ce183b..ee20cee 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -99,8 +99,8 @@ phone; the derived client has no TOTP seed of its own. - **URL:** paste the `/mcp/c/` URL. - **Authentication:** OAuth. 4. ChatGPT fetches the OAuth metadata, POSTs to the slug-gated `/oauth/register/c/` — BeaconMCP consumes the slug atomically and mints a derived client scoped to your account. -5. ChatGPT then redirects you to BeaconMCP's authorization page. Type your TOTP from your phone. Token lifetime: 24 h. -6. From now on, ChatGPT auto-refreshes via the authorization code flow. Every 24 h it re-prompts for your TOTP — no re-registration, no new slug. +5. ChatGPT then redirects you to BeaconMCP's authorization page. Type your TOTP from your phone. Access token lifetime: 24 h. +6. From now on, ChatGPT renews its access token with the refresh token it received, without prompting you, for the rest of the day: a sign-in never outlives 24 h, so you type your TOTP again once a day (or earlier if the client is revoked, or if the refresh token goes unused for `server.refresh_token_ttl`). **Revocation:** `https:///app/connectors` lists every active derived client. Revoke one and ChatGPT loses access immediately. Revoking your human account cascades to every derived client automatically. @@ -342,8 +342,9 @@ the bare `/mcp` URL and it handles the rest. - **MCP Server URL:** `https:///mcp` 3. Validate. Le Chat discovers the OAuth metadata and redirects you to BeaconMCP's authorization page — type your TOTP from your phone. - Token lifetime: 24 h; Le Chat refreshes via the authorization code - flow on its own. + Access token lifetime: 24 h; Le Chat renews it with its refresh + token on its own until the sign-in is 24 h old, then asks for your + TOTP again (once a day). Custom connectors are on Le Chat Pro / Enterprise; the free tier may hide the panel. diff --git a/docs/configuration.md b/docs/configuration.md index 7de278e..ba1ee85 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,8 +37,9 @@ Tailscale IP, a VPN address, a bastion. | `server.allowed_hosts` | DNS-rebinding allowlist. **Must** include the public FQDN behind your reverse proxy, or requests come back `421 Misdirected Request`. | | `server.allowed_origins` | Web-origin allowlist, used for browser CORS preflights and for OAuth HTTPS redirect URIs. Desktop and CLI callbacks (`vscode://`, `cursor://`, loopback) are handled separately. | | `server.trusted_proxies` | Direct peers whose forwarded headers are trusted, as IPs or CIDRs. Governs `X-Forwarded-For` (the auth rate-limit client IP) **and** `X-Forwarded-Host` (the host advertised in the OAuth issuer and the token/connector MCP URLs). When empty, both fall back to the request's own peer / `Host` and forwarded values from any peer are ignored. The value `cloudflare` auto-expands to Cloudflare's edge ranges. | -| `server.tokens_db` | SQLite file persisting *named* API tokens (the `/app/tokens` page) across restarts. Created owner-only (0600). Defaults to `tokens.db` next to `clients_file`. Env override: `BEACONMCP_TOKENS_DB`. | -| `server.named_token_ttl` | Lifetime of named API tokens, in seconds. Default `2592000` (30 days); `0` means never expires, revoke-only. Internal OAuth and session bearers keep their fixed 24 h TTL either way. Env override: `BEACONMCP_NAMED_TOKEN_TTL`. | +| `server.tokens_db` | SQLite file persisting named API tokens (the `/app/tokens` page), OAuth access tokens and OAuth refresh tokens across restarts. OAuth tokens are stored as SHA-256 hashes. Created owner-only (0600). Defaults to `tokens.db` next to `clients_file`. Env override: `BEACONMCP_TOKENS_DB`. | +| `server.named_token_ttl` | Lifetime of named API tokens, in seconds. Default `2592000` (30 days); `0` means never expires, revoke-only. OAuth access tokens and dashboard session bearers keep their fixed 24 h TTL either way. Env override: `BEACONMCP_NAMED_TOKEN_TTL`. | +| `server.refresh_token_ttl` | Idle lifetime of OAuth refresh tokens (issued to `authorization_code` clients such as claude.ai, ChatGPT or Le Chat), in seconds. Default and maximum `86400` (24 h); `0` is rejected. Every refresh rotates the token and restarts this clock, but no sign-in outlives 24 h: every refresh token of a sign-in expires 24 h after the authorization, whatever this value says, and the client goes back through the TOTP page. A shorter value only adds an idle timeout inside that day. Env override: `BEACONMCP_REFRESH_TOKEN_TTL`. | | `server.audit_log` | JSON-lines audit log covering tool calls, dashboard logins, OAuth authorize and client revokes. Created owner-only (0600). Default `/opt/beaconmcp/audit.log`; `-` keeps stderr only. Env override: `BEACONMCP_AUDIT_LOG`. | | `server.transfers_max_mb` | Size cap for `proxmox_upload_file` / `proxmox_download_file`. Default 500. | diff --git a/docs/security.md b/docs/security.md index ee92b29..bfaf401 100644 --- a/docs/security.md +++ b/docs/security.md @@ -47,6 +47,26 @@ and revoke it from `/app/tokens` the moment it leaks. API tokens survive restarts** — they live in `server.tokens_db`. Revoke them individually from `/app/tokens`, or delete `tokens.db` before restarting to kill all of them at once. +OAuth access tokens and refresh tokens also survive restarts. They are stored in the same +`tokens.db`, hashed with SHA-256, so the file alone does not hand out working credentials. Refresh +tokens are only issued on the `authorization_code` grant (never on `client_credentials`, which keeps +its TOTP-on-every-exchange rule). Each refresh rotates the token; replaying an already-used refresh +token revokes every access and refresh token of that sign-in (`auth.token.refresh.reuse` in the +audit log), and the client has to go through the authorization page again. The one exception is a +replay within 60 s of the rotation, which is read as the same client retrying after a network +timeout and gets the pair the first call minted. Deleting a client, or calling +`security_end_session`, drops its refresh tokens too. + +**A sign-in never outlives 24 h.** Every access and refresh token of a sign-in expires 24 h after +the `authorization_code` exchange, whatever `server.refresh_token_ttl` says (it is capped at 24 h and +`0` is refused): a refresh can carry a connector up to that point but never past it, so claude.ai, +ChatGPT or Le Chat ask for your TOTP once a day. Refresh tokens persist across restarts within that +day only, and rotated-out tokens disappear with the sign-in they belonged to. + +`beaconmcp auth revoke` takes effect immediately, server running or not: the server re-reads +`clients.json` when it changes on disk, and the revoked client's bearers, refresh tokens and named +API tokens are refused from its next request on. + `security_end_session` lets a client revoke its own bearer at the end of a task, which is a cheap way to shrink the replay window. diff --git a/src/beaconmcp/__main__.py b/src/beaconmcp/__main__.py index 06f5e1c..64f277e 100644 --- a/src/beaconmcp/__main__.py +++ b/src/beaconmcp/__main__.py @@ -141,6 +141,23 @@ def _log_cloudflare_unauthorized(cf_ray: str) -> None: ) +def _oauth_server_metadata(issuer: str) -> dict: + """RFC 8414 authorization-server metadata for the main issuer.""" + # registration_endpoint is intentionally omitted: dynamic client + # registration is disabled, clients must be provisioned via CLI. + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/oauth/authorize", + "token_endpoint": f"{issuer}/oauth/token", + "response_types_supported": ["code"], + "grant_types_supported": [ + "authorization_code", "refresh_token", "client_credentials", + ], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + } + + def _build_unauthorized_body(headers, *, error: str) -> dict: """Build the JSON body for a 401 on an MCP/OAuth-protected request. @@ -1084,6 +1101,7 @@ def _run_http(mcp, host: str, port: int): TOTP_REPLAY_MESSAGE, ClientStore, CodeStore, + RefreshTokenError, TokenStore, TotpResult, current_bearer_token, @@ -1143,7 +1161,41 @@ async def dispatch(self, request: Request, call_next): if env_named_ttl and env_named_ttl.isdigit() else config.server.named_token_ttl ) - token_store = TokenStore(db_path=tokens_db, named_token_ttl=named_token_ttl) + # Refresh-token idle lifetime: BEACONMCP_REFRESH_TOKEN_TTL env (seconds) > + # server.refresh_token_ttl in the YAML > TokenStore default (24 h). The + # env path skips the YAML loader, so it gets the same bounds check here: + # no sign-in outlives 24 h, and "0 = never" is gone. + env_refresh_ttl = os.environ.get("BEACONMCP_REFRESH_TOKEN_TTL") + refresh_token_ttl = ( + int(env_refresh_ttl) + if env_refresh_ttl and env_refresh_ttl.isdigit() + else config.server.refresh_token_ttl + ) + if refresh_token_ttl is not None and not ( + 0 < refresh_token_ttl <= TokenStore.SESSION_LIFETIME + ): + print( + "ERROR: BEACONMCP_REFRESH_TOKEN_TTL must be between 1 and " + f"{TokenStore.SESSION_LIFETIME} seconds (24 h); a sign-in never " + "outlives 24 h and 0 (never expires) is not supported.", + file=sys.stderr, + ) + sys.exit(1) + token_store = TokenStore( + db_path=tokens_db, + named_token_ttl=named_token_ttl, + refresh_token_ttl=refresh_token_ttl, + ) + # OAuth tokens now survive restarts: drop those of clients deleted while + # the server was down. Skipped when no client loaded at all, so an + # unreadable clients.json can't wipe the db. + known_clients = {c["client_id"] for c in client_store.list_clients()} + if known_clients: + token_store.prune_unknown_clients(known_clients) + # ...and of clients deleted while it is up: ``beaconmcp auth revoke`` + # rewrites clients.json from another process, the store notices at the + # next lookup and hands us the survivors. + client_store.on_clients_removed = token_store.prune_unknown_clients code_store = CodeStore() # Shared dashboard SQLite handle. Three features live in it -- sessions, @@ -1214,18 +1266,7 @@ def _issuer(request: Request) -> str: return f"{scheme}://{host_header}" async def oauth_metadata(request: Request) -> Response: - issuer = _issuer(request) - # registration_endpoint is intentionally omitted: dynamic client - # registration is disabled, clients must be provisioned via CLI. - return JSONResponse({ - "issuer": issuer, - "authorization_endpoint": f"{issuer}/oauth/authorize", - "token_endpoint": f"{issuer}/oauth/token", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "client_credentials"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["client_secret_post"], - }) + return JSONResponse(_oauth_server_metadata(_issuer(request))) async def protected_resource_metadata(request: Request) -> Response: # RFC 9728 - required by the MCP 2025-06-18 spec so that clients @@ -1867,13 +1908,15 @@ async def oauth_token(request: Request) -> Response: status_code=400, ) totp_record_success(client_id) - token, expires_in = token_store.issue(client_id) + # No refresh token here: every client_credentials exchange must + # carry a fresh TOTP code, a refresh token would bypass that. + grant = token_store.issue_oauth(client_id) auth_events.inc(kind="token", outcome="ok") audit.emit("auth.token.issue", client_id=client_id, grant_type=grant_type) return JSONResponse({ - "access_token": token, + "access_token": grant.access_token, "token_type": "bearer", - "expires_in": expires_in, + "expires_in": grant.expires_in, }) if grant_type == "authorization_code": @@ -1882,11 +1925,45 @@ async def oauth_token(request: Request) -> Response: code_verifier = body.get("code_verifier", "") if not code_store.consume(code, client_id, redirect_uri, code_verifier): return JSONResponse({"error": "invalid_grant"}, status_code=400) - token, expires_in = token_store.issue(client_id) + grant = token_store.issue_oauth(client_id, refresh=True) + auth_events.inc(kind="token", outcome="ok") + audit.emit("auth.token.issue", client_id=client_id, grant_type=grant_type) + return JSONResponse({ + "access_token": grant.access_token, + "token_type": "bearer", + "expires_in": grant.expires_in, + "refresh_token": grant.refresh_token, + }) + + if grant_type == "refresh_token": + presented = body.get("refresh_token", "") + family_id = token_store.family_of(presented) if presented else None + try: + if not presented: + raise RefreshTokenError("invalid") + grant = token_store.refresh(presented, client_id) + except RefreshTokenError as exc: + auth_events.inc(kind="token", outcome=f"refresh_{exc.reason}") + if exc.reason == "reused": + audit.emit( + "auth.token.refresh.reuse", + client_id=client_id, family_id=exc.family_id, + ) + else: + audit.emit( + "auth.token.fail", client_id=client_id, + reason=f"refresh_{exc.reason}", grant_type=grant_type, + ) + return JSONResponse({"error": "invalid_grant"}, status_code=400) + auth_events.inc(kind="token", outcome="ok") + audit.emit( + "auth.token.refresh", client_id=client_id, family_id=family_id, + ) return JSONResponse({ - "access_token": token, + "access_token": grant.access_token, "token_type": "bearer", - "expires_in": expires_in, + "expires_in": grant.expires_in, + "refresh_token": grant.refresh_token, }) return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) @@ -1977,6 +2054,11 @@ async def auth_middleware(request: Request, call_next): bearer = authorization[7:] client_id = token_store.validate(bearer) + # A bearer of a client revoked from the CLI dies with the client: + # ``exists`` re-reads clients.json when it changed on disk, which + # also prunes the client's tokens via ``on_clients_removed``. + if client_id and not client_store.exists(client_id): + client_id = None if not client_id: return JSONResponse( _build_unauthorized_body(request.headers, error="invalid_token"), @@ -2035,7 +2117,7 @@ async def dcr_oauth_metadata(request: Request) -> Response: "token_endpoint": f"{issuer}/oauth/token", "registration_endpoint": f"{issuer}/oauth/register/c/{slug}", "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code"], + "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_post"], }) @@ -2118,7 +2200,7 @@ async def dcr_register(request: Request) -> Response: "client_secret": new_client_secret, "client_id_issued_at": int(row.created_at), "token_endpoint_auth_method": "client_secret_post", - "grant_types": ["authorization_code"], + "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "redirect_uris": redirect_uris_raw, }, status_code=201) diff --git a/src/beaconmcp/auth.py b/src/beaconmcp/auth.py index 8b12814..de2f604 100644 --- a/src/beaconmcp/auth.py +++ b/src/beaconmcp/auth.py @@ -25,7 +25,7 @@ import sqlite3 import threading import time -from collections.abc import Iterable +from collections.abc import Callable, Iterable from contextvars import ContextVar from dataclasses import dataclass from enum import Enum @@ -213,6 +213,58 @@ class AccessToken: # not listed in the external-tokens UI. name: str | None = None created_at: float = 0.0 + # Refresh-token family the token was minted in (OAuth + # ``authorization_code`` grant only). Lets reuse detection and + # end-of-session revoke every bearer of the family at once. + family_id: str | None = None + + +@dataclass +class RefreshToken: + # SHA-256 of the raw token: only the client ever holds the raw value. + token_hash: str + client_id: str + family_id: str + expires_at: float + created_at: float + # Absolute end of the sign-in this token belongs to: the + # ``authorization_code`` exchange plus ``TokenStore.SESSION_LIFETIME``. + # Shared by every token of the family and never pushed back by a + # refresh, so a connector goes back through TOTP once a day. + family_expires_at: float + # Rotated out. Kept until the family ends so a replay can be recognised + # as reuse (and the family revoked) instead of a plain unknown token. + used: bool = False + # When it was rotated out, and the pair that rotation minted. Memory + # only, never persisted: they let a client that timed out and resent + # the very same refresh call get the pair it missed (see + # ``TokenStore.REFRESH_REUSE_GRACE``) instead of tripping reuse + # detection. Cleared once the grace window is over. + used_at: float = 0.0 + replay: OAuthGrant | None = None + + +@dataclass +class OAuthGrant: + """Result of an OAuth token issuance or refresh.""" + + access_token: str + expires_in: int + refresh_token: str | None = None + + +class RefreshTokenError(Exception): + """A refresh token was rejected. + + ``reason`` is one of ``invalid`` (unknown, rotated-out-and-purged or + owned by another client), ``expired`` or ``reused`` (an already-rotated + token was replayed: its whole family has been revoked). + """ + + def __init__(self, reason: str, *, family_id: str | None = None) -> None: + super().__init__(reason) + self.reason = reason + self.family_id = family_id @dataclass @@ -244,11 +296,30 @@ def verify_pkce(code_verifier: str, code_challenge: str, method: str) -> bool: class ClientStore: - """Persistent client credential storage backed by a JSON file.""" + """Persistent client credential storage backed by a JSON file. + + The file is shared with the ``beaconmcp auth`` CLI, which runs in its + own process while the server is up. Every public method first checks + whether the file changed on disk and re-reads it if so, so an ``auth + revoke`` cuts the client off at its next request instead of at the next + restart, and a server-side save (DCR) can't overwrite a CLI change. + """ def __init__(self, path: Path | None = None) -> None: self._path = path or CLIENTS_FILE self._clients: dict[str, Client] = {} + # Guards ``_clients`` and the on-disk signature below: reads come + # from the event loop and worker threads alike, and a reload swaps + # the whole dict. Re-entrant because ``_load`` may call ``_save``. + self._lock = threading.RLock() + # (mtime_ns, size, inode) of the file as last read or written by + # this process. ``None`` when the file does not exist yet. + self._disk_sig: tuple[int, int, int] | None = None + # Called with the ids of the surviving clients after a reload that + # dropped at least one client. The server hooks + # ``TokenStore.prune_unknown_clients`` here so the revoked client's + # OAuth bearers and refresh tokens die with it. + self.on_clients_removed: Callable[[set[str]], object] | None = None # Last accepted TOTP timestep (30s counter) per seed owner, used to # reject replays of an already-consumed code. Keyed by the SEED owner # so two clients sharing one owner's seed can't each spend the same @@ -257,8 +328,49 @@ def __init__(self, path: Path | None = None) -> None: self._totp_lock = threading.Lock() self._load() + def _stat_sig(self) -> tuple[int, int, int] | None: + """Cheap change detector for the file: mtime alone is not enough, + two CLI writes inside one kernel tick share it, so size and inode + (``_save`` replaces the file, so the inode moves) are folded in.""" + try: + st = self._path.stat() + except OSError: + return None + return (st.st_mtime_ns, st.st_size, st.st_ino) + + def _maybe_reload(self) -> None: + """Re-read ``clients.json`` if another process rewrote it.""" + sig = self._stat_sig() + if sig is None or sig == self._disk_sig: + # Unchanged, or file gone: keep what we have. A missing file is + # not "no clients" -- it is more likely a broken deploy, and + # dropping every client (and their tokens) on it would be worse. + return + with self._lock: + if sig == self._disk_sig: + return + before = set(self._clients) + try: + self._load() + except (json.JSONDecodeError, OSError): + # Half-written or broken file: keep serving the current set + # rather than nothing, and don't retry until it changes again. + self._disk_sig = sig + return + removed = before - set(self._clients) + callback = self.on_clients_removed if removed else None + if removed: + _logger.info( + "%s changed on disk: %d client(s) removed (%s)", + self._path, len(removed), ", ".join(sorted(removed)), + ) + if callback is not None: + callback(set(self._clients)) + def _load(self) -> None: - if not self._path.exists(): + sig = self._stat_sig() + if sig is None: + self._disk_sig = None return try: data = json.loads(self._path.read_text()) @@ -275,6 +387,7 @@ def _load(self) -> None: # empty seed). We log legacy-pre-2FA rows and drop them; we keep # dynamic rows. revoked: list[str] = [] + clients: dict[str, Client] = {} for c in data.get("clients", []): has_secret = bool(c.get("totp_secret")) has_owner = bool(c.get("owner_client_id")) @@ -282,7 +395,7 @@ def _load(self) -> None: revoked.append(f"{c.get('client_id', '?')} ({c.get('name', '?')})") continue try: - self._clients[c["client_id"]] = Client( + clients[c["client_id"]] = Client( client_id=c["client_id"], client_secret_hash=c["client_secret_hash"], name=c["name"], @@ -294,6 +407,8 @@ def _load(self) -> None: except (KeyError, TypeError): revoked.append(f"{c.get('client_id', '?')} ({c.get('name', '?')})") + self._clients = clients + self._disk_sig = sig if revoked: _logger.warning( "Revoked %d pre-2FA client(s) with no TOTP secret. " @@ -303,6 +418,7 @@ def _load(self) -> None: self._save() def _save(self) -> None: + """Write the file atomically, owner-only. Caller holds the lock.""" self._path.parent.mkdir(parents=True, exist_ok=True) data = { "clients": [ @@ -327,6 +443,8 @@ def _save(self) -> None: except OSError: pass os.replace(tmp, self._path) + # Our own write is not a change worth reloading. + self._disk_sig = self._stat_sig() def create(self, name: str) -> tuple[str, str, str]: """Create a new client. @@ -339,18 +457,21 @@ def create(self, name: str) -> tuple[str, str, str]: client_secret = "sk_" + secrets.token_hex(32) totp_secret = pyotp.random_base32() - self._clients[client_id] = Client( - client_id=client_id, - client_secret_hash=_hash_secret(client_secret), - name=name, - created_at=time.time(), - totp_secret=totp_secret, - ) - self._save() + self._maybe_reload() + with self._lock: + self._clients[client_id] = Client( + client_id=client_id, + client_secret_hash=_hash_secret(client_secret), + name=name, + created_at=time.time(), + totp_secret=totp_secret, + ) + self._save() return client_id, client_secret, totp_secret def verify(self, client_id: str, client_secret: str) -> bool: """Verify client credentials.""" + self._maybe_reload() client = self._clients.get(client_id) if not client: return False @@ -358,9 +479,11 @@ def verify(self, client_id: str, client_secret: str) -> bool: return hmac.compare_digest(client.client_secret_hash, _hash_secret(client_secret)) def exists(self, client_id: str) -> bool: + self._maybe_reload() return client_id in self._clients def get_name(self, client_id: str) -> str | None: + self._maybe_reload() client = self._clients.get(client_id) return client.name if client else None @@ -390,6 +513,7 @@ def check_totp(self, client_id: str, code: str) -> TotpResult: 30 s step, a browser POST replay) is an honest mistake. Counting it as a failure would lock a legitimate operator out for 5 minutes. """ + self._maybe_reload() client = self._clients.get(client_id) if not client: return TotpResult.INVALID @@ -437,6 +561,7 @@ def verify_totp(self, client_id: str, code: str) -> bool: def list_clients(self) -> list[dict[str, Any]]: """List all registered clients (without secrets).""" + self._maybe_reload() return [ { "client_id": c.client_id, @@ -450,6 +575,7 @@ def list_clients(self) -> list[dict[str, Any]]: def list_derived(self, owner_client_id: str) -> list[Client]: """Return every dynamic client delegating TOTP to this owner.""" + self._maybe_reload() return [ c for c in self._clients.values() if c.owner_client_id == owner_client_id @@ -462,19 +588,21 @@ def revoke(self, client_id: str) -> bool: it (a derived client can't authenticate without the owner's TOTP seed anyway — leaving orphaned rows around is pure clutter). """ - client = self._clients.get(client_id) - if client is None: - return False - del self._clients[client_id] - # Cascade to derived clients when the deleted row was an owner. - if client.owner_client_id is None: - derived = [ - c.client_id for c in self._clients.values() - if c.owner_client_id == client_id - ] - for cid in derived: - del self._clients[cid] - self._save() + self._maybe_reload() + with self._lock: + client = self._clients.get(client_id) + if client is None: + return False + del self._clients[client_id] + # Cascade to derived clients when the deleted row was an owner. + if client.owner_client_id is None: + derived = [ + c.client_id for c in self._clients.values() + if c.owner_client_id == client_id + ] + for cid in derived: + del self._clients[cid] + self._save() return True def create_dynamic( @@ -490,23 +618,26 @@ def create_dynamic( seed of its own; ``verify_totp`` for this client delegates to ``owner_client_id``'s seed. The owner MUST already exist. """ - if owner_client_id not in self._clients: - raise ValueError(f"unknown owner_client_id: {owner_client_id!r}") - client_id = "beaconmcp_" + secrets.token_hex(8) - client_secret = "sk_" + secrets.token_hex(32) - self._clients[client_id] = Client( - client_id=client_id, - client_secret_hash=_hash_secret(client_secret), - name=name, - created_at=time.time(), - totp_secret="", - owner_client_id=owner_client_id, - registration_source=registration_source, - ) - self._save() + self._maybe_reload() + with self._lock: + if owner_client_id not in self._clients: + raise ValueError(f"unknown owner_client_id: {owner_client_id!r}") + client_id = "beaconmcp_" + secrets.token_hex(8) + client_secret = "sk_" + secrets.token_hex(32) + self._clients[client_id] = Client( + client_id=client_id, + client_secret_hash=_hash_secret(client_secret), + name=name, + created_at=time.time(), + totp_secret="", + owner_client_id=owner_client_id, + registration_source=registration_source, + ) + self._save() return client_id, client_secret def get(self, client_id: str) -> Client | None: + self._maybe_reload() return self._clients.get(client_id) @@ -515,13 +646,17 @@ class TokenCapExceeded(Exception): class TokenStore: - """Access token store with expiration and optional named-token persistence. + """Access token store with expiration and optional persistence. + + Dashboard-session bearers (``issue`` without a name) live only in + memory -- a re-login or restart is expected to drop them. Everything a + remote client holds is persisted to a small SQLite file when + ``db_path`` is given, so a ``systemctl restart`` / redeploy no longer + silently invalidates it: - Internal (unnamed) bearers live only in memory -- a re-login or restart - is expected to drop them. *Named* tokens (the ones a human mints on the - dashboard "API tokens" page and pastes into an external client) are - persisted to a small SQLite file when ``db_path`` is given, so a - ``systemctl restart`` / redeploy no longer silently invalidates them. + * *named* tokens (dashboard "API tokens" page), stored as-is; + * OAuth access tokens and refresh tokens (``issue_oauth`` / ``refresh``), + stored as SHA-256 hashes and kept in memory keyed by that hash. """ TOKEN_TTL = 3600 * 24 # 24 hours -- internal/session bearers @@ -530,6 +665,24 @@ class TokenStore: # into an external client -- 30 days is the sensible default. Overridable # per-deployment via ``named_token_ttl`` (server.named_token_ttl / env). NAMED_TOKEN_TTL = 3600 * 24 * 30 # 30 days + # Absolute lifetime of an OAuth sign-in (``authorization_code`` grant). + # Every access and refresh token of a family expires at the exchange + # time plus this, whatever ``refresh_token_ttl`` says: a refresh can + # carry the client up to that point but never past it, so a connector + # goes back through the TOTP page once a day. This server can run shell + # on the hypervisors; a sign-in that never ends is not an option. + SESSION_LIFETIME = 3600 * 24 # 24 hours + # Idle lifetime of an OAuth refresh token: a refresh rotates it and + # restarts this clock, within the session lifetime above. Overridable + # via ``refresh_token_ttl`` (server.refresh_token_ttl / env), capped at + # ``SESSION_LIFETIME``; ``0`` (never expires) is not accepted. + REFRESH_TOKEN_TTL = SESSION_LIFETIME + # A refresh token presented again this soon after it was rotated out + # is treated as a retry of the same call (client timed out, resent) + # and gets the pair the first call minted, instead of being counted as + # reuse and killing the family -- which forced a TOTP re-login on a + # plain network retry. Strict RFC 9700 behaviour resumes after it. + REFRESH_REUSE_GRACE = 60.0 # Cap on named tokens (the ones listed in the dashboard's API # tokens page). Internal dashboard-session bearers are unlimited # because a re-login always revokes the prior one. @@ -540,8 +693,12 @@ def __init__( db_path: Path | str | None = None, *, named_token_ttl: int | None = None, + refresh_token_ttl: int | None = None, ) -> None: self._tokens: dict[str, AccessToken] = {} + # OAuth bearers and refresh tokens, keyed by SHA-256 of the raw token. + self._oauth_tokens: dict[str, AccessToken] = {} + self._refresh_tokens: dict[str, RefreshToken] = {} self._db: sqlite3.Connection | None = None # Re-entrant: tools now run on worker threads (see server._metric_tool), # so the in-memory dict is touched concurrently. Several methods hold @@ -555,6 +712,14 @@ def __init__( self.named_token_ttl = ( named_token_ttl if named_token_ttl is not None else self.NAMED_TOKEN_TTL ) + if refresh_token_ttl is None: + refresh_token_ttl = self.REFRESH_TOKEN_TTL + if not 0 < refresh_token_ttl <= self.SESSION_LIFETIME: + raise ValueError( + "refresh_token_ttl must be between 1 and " + f"{self.SESSION_LIFETIME} seconds (a sign-in never outlives 24 h)" + ) + self.refresh_token_ttl = refresh_token_ttl if db_path is not None: self._init_db(Path(db_path)) @@ -576,9 +741,30 @@ def _init_db(self, path: Path) -> None: " expires_at REAL NOT NULL," " created_at REAL NOT NULL)" ) + # OAuth material is stored hashed: a leaked tokens.db must not + # hand out working bearers or refresh tokens. + conn.execute( + "CREATE TABLE IF NOT EXISTS oauth_tokens (" + " token_hash TEXT PRIMARY KEY," + " client_id TEXT NOT NULL," + " family_id TEXT," + " expires_at REAL NOT NULL," + " created_at REAL NOT NULL)" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS refresh_tokens (" + " token_hash TEXT PRIMARY KEY," + " client_id TEXT NOT NULL," + " family_id TEXT NOT NULL," + " expires_at REAL NOT NULL," + " created_at REAL NOT NULL," + " used INTEGER NOT NULL DEFAULT 0," + " family_expires_at REAL)" + ) conn.commit() self._db = conn now = time.time() + self._bound_legacy_sessions(conn) for row in conn.execute( "SELECT token, client_id, name, expires_at, created_at " "FROM named_tokens WHERE expires_at > ?", @@ -588,13 +774,64 @@ def _init_db(self, path: Path) -> None: token=row[0], client_id=row[1], expires_at=row[3], name=row[2], created_at=row[4], ) + for row in conn.execute( + "SELECT token_hash, client_id, family_id, expires_at, created_at " + "FROM oauth_tokens WHERE expires_at > ?", + (now,), + ).fetchall(): + self._oauth_tokens[row[0]] = AccessToken( + token=row[0], client_id=row[1], expires_at=row[3], + created_at=row[4], family_id=row[2], + ) + for row in conn.execute( + "SELECT token_hash, client_id, family_id, expires_at, created_at, used, " + "family_expires_at FROM refresh_tokens WHERE expires_at > ?", + (now,), + ).fetchall(): + self._refresh_tokens[row[0]] = RefreshToken( + token_hash=row[0], client_id=row[1], family_id=row[2], + expires_at=row[3], created_at=row[4], family_expires_at=row[6], + used=bool(row[5]), + ) # Drop rows that expired while the process was down. conn.execute("DELETE FROM named_tokens WHERE expires_at <= ?", (now,)) + conn.execute("DELETE FROM oauth_tokens WHERE expires_at <= ?", (now,)) + conn.execute("DELETE FROM refresh_tokens WHERE expires_at <= ?", (now,)) conn.commit() except Exception: # noqa: BLE001 -- persistence must never block startup - _logger.exception("named-token persistence disabled (db init failed)") + _logger.exception("token persistence disabled (db init failed)") self._db = None + def _bound_legacy_sessions(self, conn: sqlite3.Connection) -> None: + """Give rows written before ``family_expires_at`` existed a session end. + + Those builds let a family live as long as it kept refreshing. Each + family is bounded from its first token (the rotated-out rows are + kept, so that row is still there), and every access or refresh + token of the family is capped at that end. + """ + try: + conn.execute("ALTER TABLE refresh_tokens ADD COLUMN family_expires_at REAL") + except sqlite3.OperationalError: + pass # column already there + conn.execute( + "UPDATE refresh_tokens SET family_expires_at = ? + (" + " SELECT MIN(created_at) FROM refresh_tokens AS first" + " WHERE first.family_id = refresh_tokens.family_id)" + " WHERE family_expires_at IS NULL", + (self.SESSION_LIFETIME,), + ) + conn.execute( + "UPDATE refresh_tokens SET expires_at = MIN(expires_at, family_expires_at)" + ) + conn.execute( + "UPDATE oauth_tokens SET expires_at = MIN(expires_at, COALESCE(" + " (SELECT MIN(family_expires_at) FROM refresh_tokens" + " WHERE refresh_tokens.family_id = oauth_tokens.family_id), expires_at))" + " WHERE family_id IS NOT NULL" + ) + conn.commit() + def _persist(self, at: AccessToken) -> None: if self._db is None or at.name is None: return @@ -620,6 +857,34 @@ def _unpersist(self, token: str) -> None: except Exception: # noqa: BLE001 _logger.exception("failed to delete persisted named token") + def _db_write(self, sql: str, params: tuple[Any, ...] = ()) -> None: + """Run one write statement against the store; never raises.""" + if self._db is None: + return + try: + with self._lock: + self._db.execute(sql, params) + self._db.commit() + except Exception: # noqa: BLE001 + _logger.exception("token store write failed") + + def _persist_oauth(self, at: AccessToken) -> None: + self._db_write( + "INSERT OR REPLACE INTO oauth_tokens " + "(token_hash, client_id, family_id, expires_at, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (at.token, at.client_id, at.family_id, at.expires_at, at.created_at), + ) + + def _persist_refresh(self, rt: RefreshToken) -> None: + self._db_write( + "INSERT OR REPLACE INTO refresh_tokens " + "(token_hash, client_id, family_id, expires_at, created_at, used, " + "family_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (rt.token_hash, rt.client_id, rt.family_id, rt.expires_at, + rt.created_at, int(rt.used), rt.family_expires_at), + ) + def issue( self, client_id: str, *, name: str | None = None, ) -> tuple[str, int]: @@ -656,6 +921,160 @@ def issue( self._cleanup() return token, ttl + # --- OAuth grants -------------------------------------------------------- + + def _mint_oauth( + self, client_id: str, family_id: str | None, family_expires_at: float, + ) -> OAuthGrant: + """Mint a persisted OAuth bearer, plus a refresh token when + ``family_id`` is given. Neither outlives ``family_expires_at``, the + absolute end of the sign-in. Caller holds the lock.""" + now = time.time() + access = secrets.token_hex(32) + access_expires_at = min(now + self.TOKEN_TTL, family_expires_at) + # ``round``, not ``int``: the family end was stamped a few µs before + # ``now`` on a fresh sign-in, and 86399.9999 must read as 24 h. + expires_in = min(self.TOKEN_TTL, max(0, round(family_expires_at - now))) + at = AccessToken( + token=_hash_secret(access), + client_id=client_id, + expires_at=access_expires_at, + created_at=now, + family_id=family_id, + ) + self._oauth_tokens[at.token] = at + self._persist_oauth(at) + refresh: str | None = None + if family_id is not None: + refresh = secrets.token_hex(32) + rt = RefreshToken( + token_hash=_hash_secret(refresh), + client_id=client_id, + family_id=family_id, + expires_at=min(now + self.refresh_token_ttl, family_expires_at), + created_at=now, + family_expires_at=family_expires_at, + ) + self._refresh_tokens[rt.token_hash] = rt + self._persist_refresh(rt) + return OAuthGrant( + access_token=access, expires_in=expires_in, refresh_token=refresh, + ) + + def issue_oauth(self, client_id: str, *, refresh: bool = False) -> OAuthGrant: + """Issue a persisted OAuth access token for ``client_id``. + + With ``refresh=True`` (``authorization_code`` grant) a refresh token + opening a new rotation family is issued alongside it. The family, + and every token it will ever mint, ends ``SESSION_LIFETIME`` from now. + """ + with self._lock: + family_id = secrets.token_hex(16) if refresh else None + grant = self._mint_oauth( + client_id, family_id, time.time() + self.SESSION_LIFETIME, + ) + self._cleanup() + return grant + + def refresh(self, refresh_token: str, client_id: str) -> OAuthGrant: + """Exchange a refresh token for a new access + refresh token pair. + + The presented token is rotated out. Replaying a rotated-out token + revokes its whole family (OAuth 2.1 / RFC 9700 reuse detection): + either the legitimate client or an attacker holds a stolen copy, + and there is no telling which. The new pair stops at the family's + ``family_expires_at``, however recent the rotation: once the sign-in + is 24 h old the client goes back through ``/oauth/authorize``. + Raises :class:`RefreshTokenError`. + """ + with self._lock: + now = time.time() + rt = self._refresh_tokens.get(_hash_secret(refresh_token)) + if rt is None or rt.client_id != client_id: + raise RefreshTokenError("invalid") + if now > rt.expires_at or now >= rt.family_expires_at: + # The whole sign-in is over with the family end; a token + # that idled past its own TTL only takes itself out. + if now >= rt.family_expires_at: + self.revoke_family(rt.family_id) + else: + self._drop_refresh(rt.token_hash) + raise RefreshTokenError("expired", family_id=rt.family_id) + if rt.used: + if ( + rt.replay is not None + and now - rt.used_at <= self.REFRESH_REUSE_GRACE + ): + return rt.replay + self.revoke_family(rt.family_id) + raise RefreshTokenError("reused", family_id=rt.family_id) + rt.used = True + rt.used_at = now + self._persist_refresh(rt) + grant = self._mint_oauth(client_id, rt.family_id, rt.family_expires_at) + rt.replay = grant + self._cleanup() + return grant + + def family_of(self, refresh_token: str) -> str | None: + """Family id of a refresh token, for audit attribution.""" + with self._lock: + rt = self._refresh_tokens.get(_hash_secret(refresh_token)) + return rt.family_id if rt is not None else None + + def _drop_refresh(self, token_hash: str) -> None: + self._refresh_tokens.pop(token_hash, None) + self._db_write("DELETE FROM refresh_tokens WHERE token_hash = ?", (token_hash,)) + + def _drop_oauth(self, token_hash: str) -> None: + self._oauth_tokens.pop(token_hash, None) + self._db_write("DELETE FROM oauth_tokens WHERE token_hash = ?", (token_hash,)) + + def revoke_family(self, family_id: str) -> None: + """Immediately drop every access and refresh token of a family.""" + with self._lock: + for h in [h for h, rt in self._refresh_tokens.items() + if rt.family_id == family_id]: + del self._refresh_tokens[h] + for h in [h for h, at in self._oauth_tokens.items() + if at.family_id == family_id]: + del self._oauth_tokens[h] + self._db_write("DELETE FROM refresh_tokens WHERE family_id = ?", (family_id,)) + self._db_write("DELETE FROM oauth_tokens WHERE family_id = ?", (family_id,)) + + def revoke_client(self, client_id: str) -> int: + """Drop every OAuth access and refresh token held by ``client_id``. + + Called when the client itself is deleted. Returns the number of + access tokens dropped. + """ + with self._lock: + hashes = [h for h, at in self._oauth_tokens.items() + if at.client_id == client_id] + for h in hashes: + del self._oauth_tokens[h] + for h in [h for h, rt in self._refresh_tokens.items() + if rt.client_id == client_id]: + del self._refresh_tokens[h] + self._db_write("DELETE FROM oauth_tokens WHERE client_id = ?", (client_id,)) + self._db_write("DELETE FROM refresh_tokens WHERE client_id = ?", (client_id,)) + return len(hashes) + + def prune_unknown_clients(self, known_client_ids: set[str]) -> int: + """Drop OAuth tokens of clients that no longer exist. + + Covers clients deleted while the server was down or from another + process (``beaconmcp auth revoke``). Returns the number of clients + pruned. + """ + with self._lock: + owners = {at.client_id for at in self._oauth_tokens.values()} + owners |= {rt.client_id for rt in self._refresh_tokens.values()} + stale = owners - known_client_ids + for cid in stale: + self.revoke_client(cid) + return len(stale) + def list_named(self, client_id: str) -> list[AccessToken]: """Return named tokens for ``client_id`` (newest first).""" with self._lock: @@ -699,7 +1118,7 @@ def validate(self, token: str) -> str | None: with self._lock: access_token = self._tokens.get(token) if not access_token: - return None + return self._validate_oauth(token) if time.time() > access_token.expires_at: if access_token.name is not None: self._unpersist(token) @@ -707,6 +1126,17 @@ def validate(self, token: str) -> str | None: return None return access_token.client_id + def _validate_oauth(self, token: str) -> str | None: + with self._lock: + token_hash = _hash_secret(token) + at = self._oauth_tokens.get(token_hash) + if at is None: + return None + if time.time() > at.expires_at: + self._drop_oauth(token_hash) + return None + return at.client_id + # Seconds to keep a revoked token alive so the current MCP response / # SSE stream has time to reach the client before the middleware starts # rejecting follow-up requests. @@ -724,7 +1154,7 @@ def revoke(self, token: str) -> bool: with self._lock: access_token = self._tokens.get(token) if access_token is None: - return False + return self._revoke_oauth(token) deadline = time.time() + self.REVOKE_GRACE_SECONDS if access_token.expires_at > deadline: access_token.expires_at = deadline @@ -734,6 +1164,31 @@ def revoke(self, token: str) -> bool: self._unpersist(token) return True + def _revoke_oauth(self, token: str) -> bool: + """OAuth flavour of :meth:`revoke`: same grace window for the bearer, + and its refresh family dies right away so the client can't quietly + mint a replacement (this is what ``security_end_session`` hits).""" + with self._lock: + token_hash = _hash_secret(token) + at = self._oauth_tokens.get(token_hash) + if at is None: + return False + deadline = time.time() + self.REVOKE_GRACE_SECONDS + if at.expires_at > deadline: + at.expires_at = deadline + # Unpersisted now, like named tokens: a restart inside the grace + # window must not resurrect it. + self._db_write("DELETE FROM oauth_tokens WHERE token_hash = ?", (token_hash,)) + if at.family_id is not None: + family_id = at.family_id + for h in [h for h, rt in self._refresh_tokens.items() + if rt.family_id == family_id]: + del self._refresh_tokens[h] + self._db_write( + "DELETE FROM refresh_tokens WHERE family_id = ?", (family_id,), + ) + return True + def _cleanup(self) -> None: with self._lock: now = time.time() @@ -742,6 +1197,16 @@ def _cleanup(self) -> None: if self._tokens[t].name is not None: self._unpersist(t) del self._tokens[t] + for h in [h for h, at in self._oauth_tokens.items() + if now > at.expires_at]: + self._drop_oauth(h) + for h in [h for h, rt in self._refresh_tokens.items() + if now > rt.expires_at]: + self._drop_refresh(h) + # Raw pairs held for the retry grace are dropped once it's over. + for rt in self._refresh_tokens.values(): + if rt.replay is not None and now - rt.used_at > self.REFRESH_REUSE_GRACE: + rt.replay = None class CodeStore: diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index c7f89fd..476a5bf 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -27,6 +27,12 @@ _logger = logging.getLogger("beaconmcp.config") +# Upper bound for ``server.refresh_token_ttl``: an OAuth sign-in never +# outlives 24 h (``auth.TokenStore.SESSION_LIFETIME``), so a longer refresh +# token would be a lie. Kept here, not imported, so the loader stays free of +# the auth module. +MAX_REFRESH_TOKEN_TTL = 3600 * 24 + # --- Dataclasses ----------------------------------------------------------- @@ -156,6 +162,13 @@ class ServerConfig: # session bearers always use the fixed 24 h TTL. The # BEACONMCP_NAMED_TOKEN_TTL env var overrides this. named_token_ttl: int | None = None + # Idle lifetime, in seconds, of OAuth refresh tokens (``authorization_code`` + # clients such as claude.ai). Each refresh rotates the token and restarts + # this clock, but no sign-in outlives 24 h (``auth.TokenStore. + # SESSION_LIFETIME``), so the value is capped there. ``None`` means the + # default (24 h); ``0`` (never expires) is rejected. The + # BEACONMCP_REFRESH_TOKEN_TTL env var overrides this. + refresh_token_ttl: int | None = None @dataclass @@ -556,12 +569,25 @@ def _build(cls, raw: dict, *, source_path: Path | None = None) -> Config: if srv_raw.get("named_token_ttl") is not None else None ), + refresh_token_ttl=( + int(srv_raw["refresh_token_ttl"]) + if srv_raw.get("refresh_token_ttl") is not None + else None + ), ) if server.named_token_ttl is not None and server.named_token_ttl < 0: raise ConfigError( "server.named_token_ttl: must be >= 0 seconds " "(0 = named tokens never expire, revoke-only)." ) + if server.refresh_token_ttl is not None and not ( + 0 < server.refresh_token_ttl <= MAX_REFRESH_TOKEN_TTL + ): + raise ConfigError( + "server.refresh_token_ttl: must be between 1 and " + f"{MAX_REFRESH_TOKEN_TTL} seconds (24 h). A sign-in never outlives " + "24 h; 0 (never expires) is not supported." + ) feat_raw = raw.get("features") or {} dash_raw = feat_raw.get("dashboard") or {} @@ -717,6 +743,11 @@ def mask(value: str) -> str: else self.server.named_token_ttl ) ), + "refresh_token_ttl": ( + "(default: 24 h)" + if self.server.refresh_token_ttl is None + else self.server.refresh_token_ttl + ), }, "proxmox": { "verify_ssl": self.verify_ssl, diff --git a/src/beaconmcp/dashboard/app.py b/src/beaconmcp/dashboard/app.py index f6b0a14..46eab7b 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -914,6 +914,8 @@ async def connectors_revoke(request: Request) -> Response: # Only allow revoking clients WE own. if target is not None and target.owner_client_id == session.client_id: deps.client_store.revoke(client_id) # type: ignore[attr-defined] + # OAuth tokens are persisted now: kill them with the client. + deps.token_store.revoke_client(client_id) # type: ignore[attr-defined] audit.emit( "auth.client.revoke", client_id=client_id, owner_client_id=session.client_id, via="dashboard", diff --git a/src/beaconmcp/wizard.py b/src/beaconmcp/wizard.py index dbbc985..0983a05 100644 --- a/src/beaconmcp/wizard.py +++ b/src/beaconmcp/wizard.py @@ -23,6 +23,8 @@ from pathlib import Path from typing import Any, Callable +from .config import MAX_REFRESH_TOKEN_TTL + try: from textual.app import App, ComposeResult from textual.binding import Binding @@ -152,6 +154,9 @@ class ServerDraft: audit_log: str = "" # Named-token lifetime in seconds; empty means the 30-day default. named_token_ttl: str = "" + # OAuth refresh-token idle lifetime in seconds; empty means the 24 h + # default, which is also the maximum (a sign-in never outlives 24 h). + refresh_token_ttl: str = "" @dataclass @@ -259,6 +264,8 @@ def render_yaml(draft: ConfigDraft) -> str: lines.append(f" audit_log: {_q(draft.server.audit_log)}") if draft.server.named_token_ttl: lines.append(f" named_token_ttl: {draft.server.named_token_ttl}") + if draft.server.refresh_token_ttl: + lines.append(f" refresh_token_ttl: {draft.server.refresh_token_ttl}") lines.append("") # Proxmox @@ -459,6 +466,11 @@ def load_yaml_into_draft(path: Path) -> ConfigDraft: if server.get("named_token_ttl") is not None else "" ) + draft.server.refresh_token_ttl = ( + str(server.get("refresh_token_ttl")) + if server.get("refresh_token_ttl") is not None + else "" + ) proxmox = raw.get("proxmox") or {} if isinstance(proxmox, dict): @@ -1240,6 +1252,18 @@ def compose(self) -> ComposeResult: id="srv-named-ttl", placeholder="2592000", ) + yield Static( + "refresh_token_ttl -- idle lifetime (seconds) of OAuth refresh " + "tokens (claude.ai and other authorization_code clients). Empty = " + "24 h, also the maximum: a sign-in never outlives 24 h, so clients " + "re-enter their TOTP once a day. 0 (never) is not accepted.", + classes="field-label", + ) + yield Input( + value=srv.refresh_token_ttl, + id="srv-refresh-ttl", + placeholder="86400", + ) def on_input_changed(self, event: Input.Changed) -> None: srv = self.draft.server @@ -1258,6 +1282,12 @@ def on_input_changed(self, event: Input.Changed) -> None: elif event.input.id == "srv-named-ttl": raw = event.value.strip() srv.named_token_ttl = raw if raw.isdigit() else "" + elif event.input.id == "srv-refresh-ttl": + raw = event.value.strip() + # Same bounds as the loader: 1..24 h, no "never". + srv.refresh_token_ttl = ( + raw if raw.isdigit() and 0 < int(raw) <= MAX_REFRESH_TOKEN_TTL else "" + ) self.on_change() def on_switch_changed(self, event: Switch.Changed) -> None: diff --git a/tests/test_client_store_reload.py b/tests/test_client_store_reload.py new file mode 100644 index 0000000..1b922c4 --- /dev/null +++ b/tests/test_client_store_reload.py @@ -0,0 +1,139 @@ +"""clients.json is shared with the ``beaconmcp auth`` CLI. + +The CLI runs in its own process while the server is up. The server used to +read the file once at startup, so ``beaconmcp auth revoke`` left the client +authenticating until the next restart -- and, with refresh tokens, able to +mint new bearers indefinitely. These tests pin the on-disk change detection. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.auth import ClientStore + + +def test_cli_revoke_is_seen_by_running_server(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + cid, secret, _ = server.create("laptop") + assert server.verify(cid, secret) + + cli = ClientStore(path) # another process + assert cli.revoke(cid) + + assert server.verify(cid, secret) is False + assert server.exists(cid) is False + assert server.get(cid) is None + assert server.get_name(cid) is None + assert server.list_clients() == [] + + +def test_cli_create_is_seen_by_running_server(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + assert server.list_clients() == [] + + cli = ClientStore(path) + cid, secret, _ = cli.create("phone") + + assert server.exists(cid) + assert server.verify(cid, secret) + + +def test_reload_reports_surviving_clients(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + keep, _, _ = server.create("keep") + gone, _, _ = server.create("gone") + seen: list[set[str]] = [] + server.on_clients_removed = seen.append + + ClientStore(path).revoke(gone) + assert server.exists(keep) + assert seen == [{keep}] + + # Repeated lookups don't re-fire: the file didn't change again. + server.exists(keep) + server.verify(keep, "wrong") + assert seen == [{keep}] + + +def test_own_writes_do_not_fire_the_callback(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + seen: list[set[str]] = [] + server.on_clients_removed = seen.append + a, _, _ = server.create("a") + b, _, _ = server.create("b") + assert server.revoke(a) + assert server.exists(b) + assert seen == [] + + +def test_cli_create_only_fires_when_clients_disappear(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + server.create("a") + seen: list[set[str]] = [] + server.on_clients_removed = seen.append + ClientStore(path).create("b") + assert len(server.list_clients()) == 2 + assert seen == [] + + +def test_server_save_does_not_clobber_a_cli_change(tmp_path: Path) -> None: + # Before the reload, a server-side save (DCR provisioning) rewrote the + # file from the startup snapshot and silently dropped CLI-created rows. + path = tmp_path / "clients.json" + server = ClientStore(path) + owner, _, _ = server.create("owner") + second, _, _ = ClientStore(path).create("second") + + derived, _ = server.create_dynamic( + owner_client_id=owner, name="chatgpt", registration_source="chatgpt:x", + ) + + fresh = ClientStore(path) + assert fresh.exists(owner) + assert fresh.exists(second) + assert fresh.exists(derived) + + +def test_broken_file_keeps_the_current_clients(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + cid, secret, _ = server.create("laptop") + + path.write_text("{not json") + assert server.verify(cid, secret) + assert server.exists(cid) + + # Once the file is readable again its content wins. + path.write_text('{"clients": []}') + assert server.exists(cid) is False + + +def test_missing_file_keeps_the_current_clients(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + cid, secret, _ = server.create("laptop") + + path.unlink() + assert server.verify(cid, secret) + + # The file coming back (restored deploy, CLI create) is picked up. + ClientStore(path).create("other") + assert server.exists(cid) is False + assert len(server.list_clients()) == 1 + + +def test_file_created_after_startup_is_picked_up(tmp_path: Path) -> None: + path = tmp_path / "clients.json" + server = ClientStore(path) + assert server.list_clients() == [] + cid, secret, _ = ClientStore(path).create("first") + assert server.verify(cid, secret) diff --git a/tests/test_oauth_refresh.py b/tests/test_oauth_refresh.py new file mode 100644 index 0000000..3f5f6c8 --- /dev/null +++ b/tests/test_oauth_refresh.py @@ -0,0 +1,664 @@ +"""OAuth refresh tokens (rotation + reuse detection) and OAuth-token persistence. + +The claude.ai connector used to drop to "needs authentication" on every +restart (OAuth bearers lived in memory only) and every time its 24 h access +token ran out. Refresh tokens fix the restart case and let a connector renew +its bearer inside the day without a prompt. The sign-in itself still ends +24 h after the authorization, by design: this server can run shell on the +hypervisors, so a session that never goes back through TOTP is not wanted. +These tests pin all three. +""" + +from __future__ import annotations + +import hashlib +import sys +import time +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from beaconmcp.auth import RefreshTokenError, TokenStore + + +def _sha(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest() + + +def _past_grace(store: TokenStore) -> None: + """Age every rotated-out token past the retry grace window, so a replay + is judged as reuse rather than as a retry of the same call.""" + for rt in store._refresh_tokens.values(): + if rt.used: + rt.used_at -= store.REFRESH_REUSE_GRACE + 1 + + +DAY = TokenStore.SESSION_LIFETIME + + +def _clock(monkeypatch: pytest.MonkeyPatch, now: float) -> None: + """Freeze the store's clock (``time.time``) at ``now``.""" + monkeypatch.setattr("beaconmcp.auth.time.time", lambda: now) + + +# --- issuance ---------------------------------------------------------------- + + +def test_authorization_code_grant_returns_refresh_token() -> None: + store = TokenStore() + grant = store.issue_oauth("client-a", refresh=True) + assert grant.access_token and grant.refresh_token + assert grant.access_token != grant.refresh_token + assert grant.expires_in == TokenStore.TOKEN_TTL + assert store.validate(grant.access_token) == "client-a" + + +def test_client_credentials_grant_has_no_refresh_token() -> None: + store = TokenStore() + grant = store.issue_oauth("client-a") + assert grant.refresh_token is None + assert store.validate(grant.access_token) == "client-a" + + +def test_refresh_token_is_not_a_valid_bearer() -> None: + store = TokenStore() + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + assert store.validate(grant.refresh_token) is None + + +# --- refresh + rotation ------------------------------------------------------ + + +def test_refresh_issues_new_access_and_refresh_token() -> None: + store = TokenStore() + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + second = store.refresh(first.refresh_token, "client-a") + assert second.refresh_token is not None + assert second.access_token != first.access_token + assert second.refresh_token != first.refresh_token + assert store.validate(second.access_token) == "client-a" + # Old access token is untouched: it simply runs out its own TTL. + assert store.validate(first.access_token) == "client-a" + + +def test_refresh_rotates_old_refresh_token_out() -> None: + store = TokenStore() + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + second = store.refresh(first.refresh_token, "client-a") + assert second.refresh_token is not None + # The rotated-out token can't be used again, the new one can. + _past_grace(store) + with pytest.raises(RefreshTokenError): + store.refresh(first.refresh_token, "client-a") + + +def test_replayed_refresh_token_revokes_whole_family() -> None: + store = TokenStore() + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + second = store.refresh(first.refresh_token, "client-a") + assert second.refresh_token is not None + + _past_grace(store) + with pytest.raises(RefreshTokenError) as exc: + store.refresh(first.refresh_token, "client-a") + assert exc.value.reason == "reused" + + # Every token of the family is dead: the legitimate holder has to + # re-authenticate, and so does whoever replayed the stolen token. + assert store.validate(first.access_token) is None + assert store.validate(second.access_token) is None + with pytest.raises(RefreshTokenError): + store.refresh(second.refresh_token, "client-a") + + +def test_reuse_does_not_touch_other_families() -> None: + store = TokenStore() + a = store.issue_oauth("client-a", refresh=True) + b = store.issue_oauth("client-a", refresh=True) + assert a.refresh_token is not None and b.refresh_token is not None + store.refresh(a.refresh_token, "client-a") + _past_grace(store) + with pytest.raises(RefreshTokenError): + store.refresh(a.refresh_token, "client-a") + assert store.validate(b.access_token) == "client-a" + store.refresh(b.refresh_token, "client-a") + + +# --- retry grace --------------------------------------------------------------- + + +def test_retry_within_grace_gets_the_same_pair() -> None: + # A client that timed out and resent the same refresh call must not be + # treated as an attacker: it gets the pair the first call minted. + store = TokenStore() + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + winner = store.refresh(first.refresh_token, "client-a") + retry = store.refresh(first.refresh_token, "client-a") + assert retry == winner + # The family is intact and keeps rotating normally. + assert store.validate(winner.access_token) == "client-a" + assert winner.refresh_token is not None + nxt = store.refresh(winner.refresh_token, "client-a") + assert nxt.refresh_token not in (first.refresh_token, winner.refresh_token) + + +def test_replay_after_grace_revokes_family() -> None: + store = TokenStore() + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + winner = store.refresh(first.refresh_token, "client-a") + _past_grace(store) + with pytest.raises(RefreshTokenError) as exc: + store.refresh(first.refresh_token, "client-a") + assert exc.value.reason == "reused" + assert store.validate(winner.access_token) is None + + +def test_grace_pair_is_dropped_once_the_window_closes() -> None: + store = TokenStore() + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + store.refresh(first.refresh_token, "client-a") + rt = store._refresh_tokens[_sha(first.refresh_token)] + assert rt.replay is not None + _past_grace(store) + store._cleanup() + assert rt.replay is None + + +def test_retry_after_restart_is_strict(tmp_path: Path) -> None: + # The minted pair is memory-only (never raw in tokens.db), so a replay + # straight after a restart falls back to the RFC 9700 behaviour. + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + winner = store.refresh(first.refresh_token, "client-a") + + reborn = TokenStore(db_path=db) + with pytest.raises(RefreshTokenError) as exc: + reborn.refresh(first.refresh_token, "client-a") + assert exc.value.reason == "reused" + assert reborn.validate(winner.access_token) is None + + +def test_refresh_with_wrong_client_rejected() -> None: + store = TokenStore() + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + with pytest.raises(RefreshTokenError) as exc: + store.refresh(grant.refresh_token, "client-b") + assert exc.value.reason == "invalid" + # The rightful owner can still use it. + store.refresh(grant.refresh_token, "client-a") + + +def test_unknown_refresh_token_rejected() -> None: + store = TokenStore() + with pytest.raises(RefreshTokenError) as exc: + store.refresh("not-a-token", "client-a") + assert exc.value.reason == "invalid" + + +def test_expired_refresh_token_rejected() -> None: + store = TokenStore(refresh_token_ttl=60) + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + store._refresh_tokens[_sha(grant.refresh_token)].expires_at = time.time() - 1 + with pytest.raises(RefreshTokenError) as exc: + store.refresh(grant.refresh_token, "client-a") + assert exc.value.reason == "expired" + + +def test_refresh_token_ttl_is_configurable() -> None: + store = TokenStore(refresh_token_ttl=3600) + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + rt = store._refresh_tokens[_sha(grant.refresh_token)] + assert 3590 < rt.expires_at - time.time() <= 3600 + + +def test_refresh_token_ttl_defaults_to_24_hours() -> None: + assert TokenStore.SESSION_LIFETIME == 86400 + assert TokenStore().refresh_token_ttl == TokenStore.SESSION_LIFETIME + + +@pytest.mark.parametrize("ttl", [0, -1, DAY + 1]) +def test_refresh_token_ttl_out_of_range_rejected(ttl: int) -> None: + # No "never expires", and nothing past the absolute session lifetime. + with pytest.raises(ValueError, match="24 h"): + TokenStore(refresh_token_ttl=ttl) + + +# --- absolute session lifetime ------------------------------------------------- + + +def test_sign_in_never_outlives_24h(monkeypatch: pytest.MonkeyPatch) -> None: + # A connector in constant use must still go back through TOTP once a + # day: a refresh at 24 h + 1 s fails even though the token it presents + # was rotated in a minute earlier. + t0 = 1_800_000_000.0 + _clock(monkeypatch, t0) + store = TokenStore() + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + + _clock(monkeypatch, t0 + DAY - 60) + late = store.refresh(grant.refresh_token, "client-a") + assert late.refresh_token is not None + # The new pair stops at the family end, not 24 h / ttl from now. + assert late.expires_in == 60 + assert store._refresh_tokens[_sha(late.refresh_token)].expires_at == t0 + DAY + + _clock(monkeypatch, t0 + DAY + 1) + with pytest.raises(RefreshTokenError) as exc: + store.refresh(late.refresh_token, "client-a") + assert exc.value.reason == "expired" + assert store.validate(late.access_token) is None + # Nothing of the sign-in is left, rotated-out tokens included. + assert store._refresh_tokens == {} + assert store._oauth_tokens == {} + + +def test_access_token_is_capped_at_the_session_end( + monkeypatch: pytest.MonkeyPatch, +) -> None: + t0 = 1_800_000_000.0 + _clock(monkeypatch, t0) + store = TokenStore() + grant = store.issue_oauth("client-a", refresh=True) + assert grant.expires_in == TokenStore.TOKEN_TTL + assert grant.refresh_token is not None + + _clock(monkeypatch, t0 + DAY - 3600) + hourly = store.refresh(grant.refresh_token, "client-a") + assert hourly.expires_in == 3600 + _clock(monkeypatch, t0 + DAY - 1) + assert store.validate(hourly.access_token) == "client-a" + _clock(monkeypatch, t0 + DAY + 1) + assert store.validate(hourly.access_token) is None + + +def test_client_credentials_bearer_keeps_its_24h( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # No family, no refresh token: the plain 24 h bearer is unchanged. + t0 = 1_800_000_000.0 + _clock(monkeypatch, t0) + store = TokenStore() + grant = store.issue_oauth("client-cli") + assert grant.expires_in == TokenStore.TOKEN_TTL + assert store._oauth_tokens[_sha(grant.access_token)].expires_at == t0 + DAY + + +def test_short_refresh_ttl_is_an_idle_timeout_inside_the_day( + monkeypatch: pytest.MonkeyPatch, +) -> None: + t0 = 1_800_000_000.0 + _clock(monkeypatch, t0) + store = TokenStore(refresh_token_ttl=DAY - 600) + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + assert store._refresh_tokens[_sha(grant.refresh_token)].expires_at == t0 + DAY - 600 + + # A refresh restarts the idle clock but the family end still wins. + _clock(monkeypatch, t0 + DAY - 1200) + nxt = store.refresh(grant.refresh_token, "client-a") + assert nxt.refresh_token is not None + assert store._refresh_tokens[_sha(nxt.refresh_token)].expires_at == t0 + DAY + + # An idle expiry takes the refresh token out, nothing else: the bearer + # runs to its own end and the family is not over yet. + _clock(monkeypatch, t0) + store2 = TokenStore(refresh_token_ttl=DAY - 600) + idle = store2.issue_oauth("client-a", refresh=True) + assert idle.refresh_token is not None + _clock(monkeypatch, t0 + DAY - 500) + with pytest.raises(RefreshTokenError) as exc: + store2.refresh(idle.refresh_token, "client-a") + assert exc.value.reason == "expired" + assert store2.validate(idle.access_token) == "client-a" + + +def test_session_end_survives_restart( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + db = tmp_path / "tokens.db" + t0 = 1_800_000_000.0 + _clock(monkeypatch, t0) + store = TokenStore(db_path=db) + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + _clock(monkeypatch, t0 + DAY - 60) + late = store.refresh(grant.refresh_token, "client-a") + assert late.refresh_token is not None + + _clock(monkeypatch, t0 + DAY - 30) + reborn = TokenStore(db_path=db) + rt = reborn._refresh_tokens[_sha(late.refresh_token)] + assert rt.family_expires_at == t0 + DAY + last = reborn.refresh(late.refresh_token, "client-a") + assert last.expires_in == 30 + assert last.refresh_token is not None + + _clock(monkeypatch, t0 + DAY + 1) + again = TokenStore(db_path=db) + # Rows past their expiry are purged while loading, so the dead sign-in + # is simply unknown to the new process. + with pytest.raises(RefreshTokenError) as exc: + again.refresh(last.refresh_token, "client-a") + assert exc.value.reason == "invalid" + assert again.validate(last.access_token) is None + assert again._db is not None + assert again._db.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] == 0 + assert again._db.execute("SELECT COUNT(*) FROM oauth_tokens").fetchone()[0] == 0 + + +def test_rows_written_before_the_session_end_existed_are_bounded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + # Earlier builds of this branch let a family live as long as it kept + # refreshing and had no family_expires_at column. Opening such a db + # adds the column, dates every family from its first token and caps + # each access or refresh token at that family's 24 h. + import sqlite3 + + db = tmp_path / "tokens.db" + t0 = 1_800_000_000.0 + conn = sqlite3.connect(str(db)) + conn.execute( + "CREATE TABLE oauth_tokens (token_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL," + " family_id TEXT, expires_at REAL NOT NULL, created_at REAL NOT NULL)" + ) + conn.execute( + "CREATE TABLE refresh_tokens (token_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL," + " family_id TEXT NOT NULL, expires_at REAL NOT NULL, created_at REAL NOT NULL," + " used INTEGER NOT NULL DEFAULT 0)" + ) + month = 30 * DAY + # Family "old": signed in 20 days ago, refreshed daily since. + conn.execute( + "INSERT INTO refresh_tokens VALUES (?, 'client-a', 'old', ?, ?, 1)", + (_sha("old-first"), t0 + month, t0), + ) + conn.execute( + "INSERT INTO refresh_tokens VALUES (?, 'client-a', 'old', ?, ?, 0)", + (_sha("old-live"), t0 + 20 * DAY + month, t0 + 20 * DAY), + ) + conn.execute( + "INSERT INTO oauth_tokens VALUES (?, 'client-a', 'old', ?, ?)", + (_sha("old-bearer"), t0 + 21 * DAY, t0 + 20 * DAY), + ) + # Family "new": signed in an hour ago. + now = t0 + 20 * DAY + 3600 + conn.execute( + "INSERT INTO refresh_tokens VALUES (?, 'client-a', 'new', ?, ?, 0)", + (_sha("new-live"), now - 3600 + month, now - 3600), + ) + conn.execute( + "INSERT INTO oauth_tokens VALUES (?, 'client-a', 'new', ?, ?)", + (_sha("new-bearer"), now - 3600 + DAY, now - 3600), + ) + # A bearer whose family rows are gone keeps its own expiry. + conn.execute( + "INSERT INTO oauth_tokens VALUES (?, 'client-a', 'orphan', ?, ?)", + (_sha("orphan-bearer"), now + 600, now - DAY + 600), + ) + conn.commit() + conn.close() + + _clock(monkeypatch, now) + store = TokenStore(db_path=db) + # The 20-day-old sign-in is over, bearer included. + assert store.validate("old-bearer") is None + with pytest.raises(RefreshTokenError): + store.refresh("old-live", "client-a") + # The hour-old one runs to its first token + 24 h and no further. + rt = store._refresh_tokens[_sha("new-live")] + assert rt.family_expires_at == now - 3600 + DAY + assert rt.expires_at == rt.family_expires_at + assert store._oauth_tokens[_sha("new-bearer")].expires_at == rt.family_expires_at + assert store.validate("orphan-bearer") == "client-a" + assert store._oauth_tokens[_sha("orphan-bearer")].expires_at == now + 600 + assert store._db is not None + cols = {r[1] for r in store._db.execute("PRAGMA table_info(refresh_tokens)")} + assert "family_expires_at" in cols + + +# --- revocation -------------------------------------------------------------- + + +def test_revoke_client_drops_access_and_refresh_tokens(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + a = store.issue_oauth("client-a", refresh=True) + b = store.issue_oauth("client-b", refresh=True) + assert a.refresh_token is not None and b.refresh_token is not None + + assert store.revoke_client("client-a") == 1 + assert store.validate(a.access_token) is None + with pytest.raises(RefreshTokenError): + store.refresh(a.refresh_token, "client-a") + assert store.validate(b.access_token) == "client-b" + + reborn = TokenStore(db_path=db) + assert reborn.validate(a.access_token) is None + assert reborn.validate(b.access_token) == "client-b" + + +def test_end_session_revoke_kills_refresh_family() -> None: + store = TokenStore() + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + assert store.revoke(grant.access_token) is True + # Grace window keeps the in-flight response alive... + assert store.validate(grant.access_token) == "client-a" + # ...but the client can't silently mint a fresh bearer afterwards. + with pytest.raises(RefreshTokenError): + store.refresh(grant.refresh_token, "client-a") + + +def test_prune_unknown_clients(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + keep = store.issue_oauth("client-a", refresh=True) + gone = store.issue_oauth("client-gone", refresh=True) + + reborn = TokenStore(db_path=db) + reborn.prune_unknown_clients({"client-a"}) + assert reborn.validate(keep.access_token) == "client-a" + assert reborn.validate(gone.access_token) is None + + +# --- persistence ------------------------------------------------------------- + + +def test_oauth_access_token_survives_restart(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + grant = store.issue_oauth("client-a", refresh=True) + cc = store.issue_oauth("client-cli") + + reborn = TokenStore(db_path=db) + assert reborn.validate(grant.access_token) == "client-a" + assert reborn.validate(cc.access_token) == "client-cli" + + +def test_refresh_token_survives_restart(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + + reborn = TokenStore(db_path=db) + fresh = reborn.refresh(grant.refresh_token, "client-a") + assert reborn.validate(fresh.access_token) == "client-a" + + +def test_reuse_detected_across_restart(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + first = store.issue_oauth("client-a", refresh=True) + assert first.refresh_token is not None + second = store.refresh(first.refresh_token, "client-a") + + reborn = TokenStore(db_path=db) + with pytest.raises(RefreshTokenError) as exc: + reborn.refresh(first.refresh_token, "client-a") + assert exc.value.reason == "reused" + assert reborn.validate(second.access_token) is None + + # The family revocation itself is durable too. + again = TokenStore(db_path=db) + assert again.validate(second.access_token) is None + + +def test_oauth_tokens_stored_hashed(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + grant = store.issue_oauth("client-a", refresh=True) + assert grant.refresh_token is not None + raw = db.read_bytes() + assert grant.access_token.encode() not in raw + assert grant.refresh_token.encode() not in raw + assert store._db is not None + hashes = { + r[0] for r in store._db.execute("SELECT token_hash FROM oauth_tokens") + } | {r[0] for r in store._db.execute("SELECT token_hash FROM refresh_tokens")} + assert _sha(grant.access_token) in hashes + assert _sha(grant.refresh_token) in hashes + + +def test_expired_oauth_rows_purged_on_load(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + grant = store.issue_oauth("client-a", refresh=True) + assert store._db is not None + store._db.execute("UPDATE oauth_tokens SET expires_at = 1.0") + store._db.execute("UPDATE refresh_tokens SET expires_at = 1.0") + store._db.commit() + + reborn = TokenStore(db_path=db) + assert reborn.validate(grant.access_token) is None + assert reborn._db is not None + assert reborn._db.execute("SELECT COUNT(*) FROM oauth_tokens").fetchone()[0] == 0 + assert reborn._db.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] == 0 + + +def test_dashboard_session_bearer_still_memory_only(tmp_path: Path) -> None: + db = tmp_path / "tokens.db" + store = TokenStore(db_path=db) + session, _ = store.issue("client-a") + reborn = TokenStore(db_path=db) + assert reborn.validate(session) is None + + +def test_existing_named_tokens_db_upgrades_in_place(tmp_path: Path) -> None: + # A tokens.db written by a release without OAuth persistence only has + # the named_tokens table; opening it must add the new tables and keep + # the named tokens. + db = tmp_path / "tokens.db" + import sqlite3 + + conn = sqlite3.connect(str(db)) + conn.execute( + "CREATE TABLE named_tokens (token TEXT PRIMARY KEY, client_id TEXT NOT NULL," + " name TEXT NOT NULL, expires_at REAL NOT NULL, created_at REAL NOT NULL)" + ) + conn.execute( + "INSERT INTO named_tokens VALUES (?, ?, ?, ?, ?)", + ("legacy-token", "client-a", "laptop", time.time() + 3600, time.time()), + ) + conn.commit() + conn.close() + + store = TokenStore(db_path=db) + assert store.validate("legacy-token") == "client-a" + grant = store.issue_oauth("client-a", refresh=True) + assert TokenStore(db_path=db).validate(grant.access_token) == "client-a" + + +# --- metadata ---------------------------------------------------------------- + + +def test_metadata_advertises_refresh_token_grant() -> None: + from beaconmcp.__main__ import _oauth_server_metadata + + meta = _oauth_server_metadata("https://beacon.example") + assert "refresh_token" in meta["grant_types_supported"] + assert "authorization_code" in meta["grant_types_supported"] + assert "client_credentials" in meta["grant_types_supported"] + assert meta["token_endpoint"] == "https://beacon.example/oauth/token" + + +# --- config ------------------------------------------------------------------ + + +def test_refresh_token_ttl_from_yaml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from beaconmcp.config import Config, ConfigError + + monkeypatch.setenv("VPS_PW", "pw") + base = """ +version: 1 +server: + refresh_token_ttl: {value} +ssh: + hosts: + - name: vps1 + host: 198.51.100.10 + user: root + password: ${{VPS_PW}} +""" + + def load(value: int) -> Config: + path = tmp_path / f"cfg{value}.yaml" + path.write_text(base.format(value=value), encoding="utf-8") + return Config.load(config_path=path) + + assert load(86400).server.refresh_token_ttl == 86400 + assert load(3600).server.refresh_token_ttl == 3600 + # No "never expires", and nothing past the 24 h session lifetime. + for bad in (0, -1, 86401, 2592000): + with pytest.raises(ConfigError, match="refresh_token_ttl"): + load(bad) + + +def test_refresh_token_ttl_round_trips_through_wizard(tmp_path: Path) -> None: + from beaconmcp.wizard import load_yaml_into_draft, render_yaml + + cfg = tmp_path / "beaconmcp.yaml" + cfg.write_text("version: 1\nserver:\n refresh_token_ttl: 3600\n", encoding="utf-8") + draft = load_yaml_into_draft(cfg) + assert draft.server.refresh_token_ttl == "3600" + assert "refresh_token_ttl: 3600" in render_yaml(draft) + + +def test_wizard_input_rejects_out_of_range_refresh_ttl() -> None: + from types import SimpleNamespace + + from beaconmcp.wizard import MAX_REFRESH_TOKEN_TTL, _ServerPanel + + panel = _ServerPanel.__new__(_ServerPanel) + panel.draft = SimpleNamespace(server=SimpleNamespace(refresh_token_ttl="")) + panel.on_change = lambda: None + assert MAX_REFRESH_TOKEN_TTL == 86400 + + def typed(raw: str) -> str: + event = SimpleNamespace(input=SimpleNamespace(id="srv-refresh-ttl"), value=raw) + _ServerPanel.on_input_changed(panel, event) + return panel.draft.server.refresh_token_ttl + + assert typed("3600") == "3600" + assert typed("86400") == "86400" + assert typed("0") == "" + assert typed("86401") == "" + assert typed("2592000") == ""