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
5 changes: 3 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ Things worth knowing before you enrol:
- Registrations, revocations and passkey sign-ins are recorded in the audit log
(`dashboard.passkey.*`, `auth.passkey.*`, and `login`/`authorize` events tagged `via=passkey`).

Passkey ceremonies are rate-limited per IP by the same limiter that guards `/app/login`, and a TOTP
lockout also blocks the passkey path for that client.
Passkey ceremonies are rate-limited per IP by the same limiter that guards `/app/login`. A TOTP
lockout does not block them: a passkey cannot be guessed, so it stays the owner's way in while
someone burns the client's TOTP budget.

Manage enrolled credentials from `/app/tokens`, or drop them all for a client with:

Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| MCP returns 401/403 only via the public Cloudflare URL (works on `localhost`) | Cloudflare Bot Fight Mode / WAF blocks the headless MCP client, or Cloudflare Access strips the `Authorization` header | Add a WAF skip rule + cache bypass for the MCP/OAuth paths and exclude `/mcp` from Access. See **[docs/cloudflare.md](cloudflare.md)**. A 401 body whose `hint` mentions `cf-ray` confirms the header was stripped at the edge. |
| `{"error":"unauthorized"}` on `/authorize` | Client id not registered server-side | Create the client with `beaconmcp auth create`, then paste the returned id into the connector. |
| `invalid_grant` with `missing or invalid totp` | 2FA code wrong, expired (>30 s), or already used | Generate a fresh code. Compare the server clock against the phone (`timedatectl` vs. authenticator). |
| `Too many attempts` on the 2FA page | 5 consecutive failed TOTP codes triggered a 5-minute lockout | Wait. The counter resets on the next valid code. |
| `Too many attempts` on the 2FA page | 5 failed TOTP codes from your address (or 10 for the client from any mix of addresses) triggered a 5-minute lockout | Wait, or sign in with a passkey, which the lockout does not block. Behind a reverse proxy, make sure `server.trusted_proxies` lists it, otherwise every visitor shares one budget. |
| Clients silently rejected after upgrade | 2FA migration: clients without a TOTP secret are refused at startup | Inspect `journalctl -u beaconmcp` for the list, recreate them with `beaconmcp auth create`. |
| Dashboard loops between `/app/refresh` and `/app/chat` | Bearer wiped by a service restart while the session cookie is still valid | Enter the TOTP code on the refresh page to mint a new bearer. |
| External MCP clients disconnected after a restart | `TokenStore` is in-memory and wiped on restart | Recreate tokens from `/app/tokens` (max 3, 24 h expiry). |
Expand Down
89 changes: 39 additions & 50 deletions src/beaconmcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1106,7 +1106,7 @@ def _run_http(mcp, host: str, port: int):
TotpResult,
current_bearer_token,
)
from .ratelimit import RateLimiter, client_ip, forwarded_host
from .ratelimit import RateLimiter, TotpLockout, client_ip, forwarded_host
from .server import config

from .metrics import REGISTRY, auth_events, http_requests
Expand Down Expand Up @@ -1230,31 +1230,18 @@ async def dispatch(self, request: Request, call_next):
# the caller's bearer without an import cycle.
auth.register_token_store(token_store)

# In-memory TOTP bruteforce guard: per client_id, (failures, cooldown_until).
# 5 failed attempts → 5-minute lockout. Reset on first success.
totp_fail_max = 5
totp_lockout_seconds = 300
totp_failures: dict[str, tuple[int, float]] = {}
# In-memory TOTP brute-force guard, keyed on (client_id, source IP): 5
# wrong codes from one IP lock that IP out for 5 minutes, and 10 from any
# mix of IPs lock the client's TOTP (passkeys stay usable). Keyed on
# client_id alone, anyone who knew a client_id could lock its owner out
# of every 2FA path.
totp_lockout = TotpLockout(per_source=5, per_client=10, lockout_seconds=300)
totp_locked = totp_lockout.locked
totp_record_failure = totp_lockout.record_failure
totp_record_success = totp_lockout.record_success

def totp_locked(client_id: str) -> bool:
entry = totp_failures.get(client_id)
if not entry:
return False
count, until = entry
if count < totp_fail_max:
return False
if time.time() >= until:
totp_failures.pop(client_id, None)
return False
return True

def totp_record_failure(client_id: str) -> None:
count, _ = totp_failures.get(client_id, (0, 0.0))
count += 1
totp_failures[client_id] = (count, time.time() + totp_lockout_seconds)

def totp_record_success(client_id: str) -> None:
totp_failures.pop(client_id, None)
def _ip(request: Request) -> str:
return client_ip(request, tuple(config.server.trusted_proxies))

def _issuer(request: Request) -> str:
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
Expand Down Expand Up @@ -1619,7 +1606,7 @@ async def oauth_authorize_get(request: Request) -> Response:
return _authorize_page(
normalized,
request=request,
locked=totp_locked(normalized["client_id"]),
locked=totp_locked(normalized["client_id"], _ip(request)),
)

async def oauth_authorize_post(request: Request) -> Response:
Expand All @@ -1629,8 +1616,21 @@ async def oauth_authorize_post(request: Request) -> Response:
if err is not None:
return err

# This endpoint takes a TOTP guess with nothing but a client_id, so
# it gets the same per-IP limit as /app/login.
ip = _ip(request)
if not _login_limiter.check(ip):
retry = _login_limiter.retry_after(ip)
response = _authorize_failed(
request, normalized,
f"Too many attempts. Retry in {retry}s.",
status=429,
)
response.headers["Retry-After"] = str(retry)
return response

client_id = normalized["client_id"]
if totp_locked(client_id):
if totp_locked(client_id, ip):
return _authorize_failed(
request, normalized,
"Too many attempts. Try again in 5 minutes.",
Expand All @@ -1643,11 +1643,10 @@ async def oauth_authorize_post(request: Request) -> Response:
# A replayed code is the operator re-submitting one they already
# spent, not an attack -- it must not count towards the lockout.
if totp_result is TotpResult.INVALID:
totp_record_failure(client_id)
totp_record_failure(client_id, ip)
audit.emit(
"auth.authorize.fail", client_id=client_id,
reason=totp_result.value,
ip=client_ip(request, tuple(config.server.trusted_proxies)),
reason=totp_result.value, ip=ip,
)
return _authorize_failed(
request, normalized,
Expand All @@ -1656,13 +1655,10 @@ async def oauth_authorize_post(request: Request) -> Response:
if totp_result is TotpResult.REPLAY
else "Incorrect code. Check that your device clock is in sync."
),
locked=totp_locked(client_id),
locked=totp_locked(client_id, ip),
)
totp_record_success(client_id)
audit.emit(
"auth.authorize.2fa", client_id=client_id,
ip=client_ip(request, tuple(config.server.trusted_proxies)),
)
totp_record_success(client_id, ip)
audit.emit("auth.authorize.2fa", client_id=client_id, ip=ip)
return _authorize_approved(request, normalized)

async def oauth_authorize_finalize(request: Request) -> Response:
Expand Down Expand Up @@ -1726,11 +1722,9 @@ async def oauth_passkey_options(request: Request) -> Response:
return JSONResponse(
{"ok": False, "error": "Unknown client."}, status_code=400,
)
if totp_locked(client_id):
return JSONResponse(
{"ok": False, "error": "Too many attempts. Try again in 5 minutes."},
status_code=429,
)
# No TOTP-lockout check on the passkey path: a passkey cannot be
# guessed, so it stays the owner's way in while someone burns the
# client's TOTP budget. The per-IP guard above still applies.
try:
options, state = passkey_service.authentication_options(
request, client_id=_passkey_owner(client_id),
Expand Down Expand Up @@ -1759,11 +1753,6 @@ async def oauth_passkey_verify(request: Request) -> Response:
status_code=400,
)
client_id = normalized["client_id"]
if totp_locked(client_id):
return JSONResponse(
{"ok": False, "error": "Too many attempts. Try again in 5 minutes."},
status_code=429,
)
credential = body.get("credential")
state = str(body.get("state") or "")
if not isinstance(credential, dict) or not state:
Expand All @@ -1789,7 +1778,7 @@ async def oauth_passkey_verify(request: Request) -> Response:
{"ok": False, "error": "This passkey belongs to another client."},
status_code=401,
)
totp_record_success(client_id)
totp_record_success(client_id, _ip(request))
audit.emit(
"auth.authorize.2fa", client_id=client_id, via="passkey",
ip=client_ip(request, tuple(config.server.trusted_proxies)),
Expand Down Expand Up @@ -1882,7 +1871,7 @@ async def oauth_token(request: Request) -> Response:
# 2FA mandatory on every client_credentials exchange (design choice:
# no non-interactive escape hatch, the operator must re-type a TOTP
# code at every 24 h token refresh).
if totp_locked(client_id):
if totp_locked(client_id, ip):
return JSONResponse(
{
"error": "invalid_grant",
Expand All @@ -1895,7 +1884,7 @@ async def oauth_token(request: Request) -> Response:
if totp_result is not TotpResult.OK:
# Replays don't count towards the lockout -- see /authorize.
if totp_result is TotpResult.INVALID:
totp_record_failure(client_id)
totp_record_failure(client_id, ip)
return JSONResponse(
{
"error": "invalid_grant",
Expand All @@ -1907,7 +1896,7 @@ async def oauth_token(request: Request) -> Response:
},
status_code=400,
)
totp_record_success(client_id)
totp_record_success(client_id, ip)
# 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)
Expand Down
Loading
Loading