Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions beaconmcp.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ phone; the derived client has no TOTP seed of its own.
- **URL:** paste the `/mcp/c/<slug>` URL.
- **Authentication:** OAuth.
4. ChatGPT fetches the OAuth metadata, POSTs to the slug-gated `/oauth/register/c/<slug>` — 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://<your-host>/app/connectors` lists every active derived client. Revoke one and ChatGPT loses access immediately. Revoking your human account cascades to every derived client automatically.

Expand Down Expand Up @@ -342,8 +342,9 @@ the bare `/mcp` URL and it handles the rest.
- **MCP Server URL:** `https://<your-host>/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.
Expand Down
5 changes: 3 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
20 changes: 20 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
124 changes: 103 additions & 21 deletions src/beaconmcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -1084,6 +1101,7 @@ def _run_http(mcp, host: str, port: int):
TOTP_REPLAY_MESSAGE,
ClientStore,
CodeStore,
RefreshTokenError,
TokenStore,
TotpResult,
current_bearer_token,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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)
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"],
})
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading