Skip to content
Open
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
6 changes: 6 additions & 0 deletions beaconmcp.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 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. 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://<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,8 @@ 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.

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 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. |

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

Expand Down
103 changes: 82 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,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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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)
Expand Down Expand Up @@ -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"],
})
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading