From 633f254e90000d4aafaa6ab45427b6f6b438e28c Mon Sep 17 00:00:00 2001 From: Ailcope <54411234+Ailcope@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:34:49 +0200 Subject: [PATCH] feat(auth): oauth refresh tokens and persisted oauth tokens OAuth access tokens only lived in memory and no refresh token was issued, so an authorization_code client like claude.ai fell back to "needs authentication" on every restart and at least once every 24 h, forcing a manual passkey/TOTP sign-in each time. - /oauth/token returns a refresh_token on the authorization_code grant (never on client_credentials, which keeps its TOTP-on-every-exchange rule) - new refresh_token grant: same client_secret_post check, rotates the refresh token and issues a new access token; replaying a rotated-out token revokes the whole family (OAuth 2.1 / RFC 9700 reuse detection) - OAuth access and refresh tokens are persisted in tokens.db as SHA-256 hashes (new oauth_tokens / refresh_tokens tables, created in place), reloaded at startup, expired rows purged; dashboard session bearers stay memory-only - server.refresh_token_ttl (default 30 days, 0 = never expires), env BEACONMCP_REFRESH_TOKEN_TTL, wizard field and config summary - refresh_token added to grant_types_supported (main and DCR metadata) and to the DCR registration response - deleting a connector drops its OAuth tokens; tokens of clients missing from clients.json are pruned at startup; security_end_session also drops the refresh family - audit: auth.token.refresh, auth.token.refresh.reuse, and auth.token.issue now also emitted for authorization_code - docs: configuration, security, clients, yaml example Tests: 498 passed. --- beaconmcp.yaml.example | 6 + docs/clients.md | 8 +- docs/configuration.md | 5 +- docs/security.md | 8 + src/beaconmcp/__main__.py | 103 ++++++++-- src/beaconmcp/auth.py | 315 +++++++++++++++++++++++++++- src/beaconmcp/config.py | 25 +++ src/beaconmcp/dashboard/app.py | 2 + src/beaconmcp/wizard.py | 23 +++ tests/test_oauth_refresh.py | 363 +++++++++++++++++++++++++++++++++ 10 files changed, 822 insertions(+), 36 deletions(-) create mode 100644 tests/test_oauth_refresh.py diff --git a/beaconmcp.yaml.example b/beaconmcp.yaml.example index 3ceda3b..1d1a2dd 100644 --- a/beaconmcp.yaml.example +++ b/beaconmcp.yaml.example @@ -87,6 +87,12 @@ server: # sure you trust every client you paste one into). The # BEACONMCP_NAMED_TOKEN_TTL env var overrides this. # named_token_ttl: 2592000 + # Lifetime (seconds) of OAuth refresh tokens handed to authorization_code + # clients (claude.ai, ChatGPT, Le Chat...). Each refresh rotates the token + # and restarts the clock; replaying a rotated-out token revokes the whole + # chain. Default 30 days (2592000); 0 = never expires. The + # BEACONMCP_REFRESH_TOKEN_TTL env var overrides this. + # refresh_token_ttl: 2592000 # -------- 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..00e748b 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. You only sign in again if the refresh token goes unused for `server.refresh_token_ttl` (30 days by default) or gets revoked. **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,8 @@ 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. 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 9f1b14b..d9a8123 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 allowed to supply `X-Forwarded-For`, as IPs or CIDRs. 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` | Lifetime of OAuth refresh tokens (issued to `authorization_code` clients such as claude.ai, ChatGPT or Le Chat), in seconds. Default `2592000` (30 days); `0` means never expires. Every refresh rotates the token and restarts the clock, so a client in regular use never has to sign in again. 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..1b97511 100644 --- a/docs/security.md +++ b/docs/security.md @@ -47,6 +47,14 @@ 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. Deleting a client, or +calling `security_end_session`, drops its refresh tokens too. + `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 f711726..9115a09 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,25 @@ 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 lifetime: BEACONMCP_REFRESH_TOKEN_TTL env (seconds) > + # server.refresh_token_ttl in the YAML > TokenStore default (30 days). + 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 + ) + 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 (or via ``beaconmcp auth revoke``). 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) code_store = CodeStore() # Shared dashboard SQLite handle. Three features live in it -- sessions, @@ -1212,18 +1248,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 @@ -1865,13 +1890,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": @@ -1880,11 +1907,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) @@ -2033,7 +2094,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"], }) @@ -2116,7 +2177,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..8d214f2 100644 --- a/src/beaconmcp/auth.py +++ b/src/beaconmcp/auth.py @@ -213,6 +213,46 @@ 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 + # Rotated out. Kept until it expires so a replay can be recognised as + # reuse (and the family revoked) instead of a plain unknown token. + used: bool = False + + +@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 @@ -515,13 +555,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 +574,11 @@ 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 + # Default lifetime of an OAuth refresh token. Every refresh rotates it + # and restarts the clock, so a client in regular use never has to + # re-authenticate. Overridable via ``refresh_token_ttl`` + # (server.refresh_token_ttl / env); ``0`` means never expires. + REFRESH_TOKEN_TTL = 3600 * 24 * 30 # 30 days # 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 +589,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 +608,11 @@ def __init__( self.named_token_ttl = ( named_token_ttl if named_token_ttl is not None else self.NAMED_TOKEN_TTL ) + self.refresh_token_ttl = ( + refresh_token_ttl + if refresh_token_ttl is not None + else self.REFRESH_TOKEN_TTL + ) if db_path is not None: self._init_db(Path(db_path)) @@ -576,6 +634,25 @@ 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)" + ) conn.commit() self._db = conn now = time.time() @@ -588,11 +665,31 @@ 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 " + "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], 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 _persist(self, at: AccessToken) -> None: @@ -620,6 +717,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) " + "VALUES (?, ?, ?, ?, ?, ?)", + (rt.token_hash, rt.client_id, rt.family_id, rt.expires_at, + rt.created_at, int(rt.used)), + ) + def issue( self, client_id: str, *, name: str | None = None, ) -> tuple[str, int]: @@ -656,6 +781,136 @@ def issue( self._cleanup() return token, ttl + # --- OAuth grants -------------------------------------------------------- + + def _mint_oauth( + self, client_id: str, family_id: str | None, + ) -> OAuthGrant: + """Mint a persisted OAuth bearer, plus a refresh token when + ``family_id`` is given. Caller holds the lock.""" + now = time.time() + access = secrets.token_hex(32) + at = AccessToken( + token=_hash_secret(access), + client_id=client_id, + expires_at=now + self.TOKEN_TTL, + 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) + ttl = self.refresh_token_ttl + rt = RefreshToken( + token_hash=_hash_secret(refresh), + client_id=client_id, + family_id=family_id, + expires_at=float("inf") if ttl == 0 else now + ttl, + created_at=now, + ) + self._refresh_tokens[rt.token_hash] = rt + self._persist_refresh(rt) + return OAuthGrant( + access_token=access, expires_in=self.TOKEN_TTL, 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. + """ + with self._lock: + family_id = secrets.token_hex(16) if refresh else None + grant = self._mint_oauth(client_id, family_id) + 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. Raises :class:`RefreshTokenError`. + """ + with self._lock: + rt = self._refresh_tokens.get(_hash_secret(refresh_token)) + if rt is None or rt.client_id != client_id: + raise RefreshTokenError("invalid") + if time.time() > rt.expires_at: + self._drop_refresh(rt.token_hash) + raise RefreshTokenError("expired", family_id=rt.family_id) + if rt.used: + self.revoke_family(rt.family_id) + raise RefreshTokenError("reused", family_id=rt.family_id) + rt.used = True + self._persist_refresh(rt) + grant = self._mint_oauth(client_id, rt.family_id) + 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 +954,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 +962,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 +990,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 +1000,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 +1033,12 @@ 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) class CodeStore: diff --git a/src/beaconmcp/config.py b/src/beaconmcp/config.py index c7f89fd..28e4a12 100644 --- a/src/beaconmcp/config.py +++ b/src/beaconmcp/config.py @@ -156,6 +156,12 @@ 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 + # Lifetime, in seconds, of OAuth refresh tokens (``authorization_code`` + # clients such as claude.ai). Each refresh rotates the token and restarts + # the clock. ``None`` means the ``auth.TokenStore`` default (30 days); + # ``0`` means never expires. The BEACONMCP_REFRESH_TOKEN_TTL env var + # overrides this. + refresh_token_ttl: int | None = None @dataclass @@ -556,12 +562,22 @@ 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 server.refresh_token_ttl < 0: + raise ConfigError( + "server.refresh_token_ttl: must be >= 0 seconds " + "(0 = refresh tokens never expire)." + ) feat_raw = raw.get("features") or {} dash_raw = feat_raw.get("dashboard") or {} @@ -717,6 +733,15 @@ def mask(value: str) -> str: else self.server.named_token_ttl ) ), + "refresh_token_ttl": ( + "(default: 30 days)" + if self.server.refresh_token_ttl is None + else ( + "0 (never expires)" + if self.server.refresh_token_ttl == 0 + 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 699c01d..858d8d6 100644 --- a/src/beaconmcp/dashboard/app.py +++ b/src/beaconmcp/dashboard/app.py @@ -915,6 +915,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..ac13a8b 100644 --- a/src/beaconmcp/wizard.py +++ b/src/beaconmcp/wizard.py @@ -152,6 +152,8 @@ class ServerDraft: audit_log: str = "" # Named-token lifetime in seconds; empty means the 30-day default. named_token_ttl: str = "" + # OAuth refresh-token lifetime in seconds; empty means the 30-day default. + refresh_token_ttl: str = "" @dataclass @@ -259,6 +261,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 +463,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 +1249,17 @@ def compose(self) -> ComposeResult: id="srv-named-ttl", placeholder="2592000", ) + yield Static( + "refresh_token_ttl -- lifetime (seconds) of OAuth refresh tokens " + "(claude.ai and other authorization_code clients). Empty = 30 " + "days; 0 = never expires. Each refresh restarts the clock.", + classes="field-label", + ) + yield Input( + value=srv.refresh_token_ttl, + id="srv-refresh-ttl", + placeholder="2592000", + ) def on_input_changed(self, event: Input.Changed) -> None: srv = self.draft.server @@ -1258,6 +1278,9 @@ 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() + srv.refresh_token_ttl = raw if raw.isdigit() else "" self.on_change() def on_switch_changed(self, event: Switch.Changed) -> None: diff --git a/tests/test_oauth_refresh.py b/tests/test_oauth_refresh.py new file mode 100644 index 0000000..ca83fce --- /dev/null +++ b/tests/test_oauth_refresh.py @@ -0,0 +1,363 @@ +"""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 at least once a day (24 h +access token, no refresh token). These tests pin both fixes. +""" + +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() + + +# --- 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. + 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 + + 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") + 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") + + +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_30_days() -> None: + assert TokenStore().refresh_token_ttl == 3600 * 24 * 30 + + +def test_refresh_token_ttl_zero_never_expires() -> None: + store = TokenStore(refresh_token_ttl=0) + 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 == float("inf") + + +# --- 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 + # 0 is a deliberate setting (never expires) and must survive parsing. + assert load(0).server.refresh_token_ttl == 0 + with pytest.raises(ConfigError, match="refresh_token_ttl"): + load(-1) + + +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: 0\n", encoding="utf-8") + draft = load_yaml_into_draft(cfg) + assert draft.server.refresh_token_ttl == "0" + assert "refresh_token_ttl: 0" in render_yaml(draft)