mcp-stepup-gateway
by joaorura
README.md
# mcp-stepup-gateway
Um gateway MCP que exige **passkey (WebAuthn) sob demanda** -- "step-up
auth" -- antes de permitir que um cliente remoto (Claude.ai, via Custom
Connector) leia ou escreva num vault Obsidian protegido pelo
[`enquire-mcp`](https://github.com/oomkapwn/enquire-mcp). Login Google e
allowlist (como no `mcp-oauth-gateway`) decidem **quem** pode conectar;
este projeto decide, tool a tool, **o que** essa pessoa pode fazer sem
reprovar identidade de novo, e o que exige um toque fresco na passkey.
Nasceu de um caso concreto: o `mcp-oauth-gateway`/`enquire-mcp-gateway` ja
resolvem "autenticar quem conecta" (OAuth + allowlist). O que faltava era
uma segunda camada: mesmo dentro da allowlist, nem toda tool call deveria
ser igualmente livre. Ler uma nota e barato; apagar ou reescrever conteudo
do vault via uma LLM que pode estar sob prompt injection não é. Este
gateway adiciona essa distincao sem tocar no `enquire-mcp` em si.
## Por que isso existe
Um cliente MCP remoto autenticado por OAuth ainda e, do ponto de vista do
vault, "uma LLM com acesso total". Isso e um problema em dois eixos:
1. **A LLM pode ser manipulada.** Conteudo malicioso numa nota, ou numa
resposta de ferramenta, pode tentar instruir o agente a apagar ou
sobrescrever coisas -- prompt injection nao e hipotetico.
2. **"Autenticado uma vez" nao deveria significar "autorizado para
sempre".** Uma sessao OAuth de longa duracao nao deveria dar a mesma
LLM permissao irrestrita de escrita indefinidamente, sem nenhuma prova
fresca de presenca humana.
A solucao aqui e um modelo de **niveis de risco por tool**, com um
**handle de capacidade** de vida curta (15 min) que autoriza leitura, e uma
**confirmacao com passkey por chamada** que autoriza qualquer escrita ou
delecao -- renderizada a partir dos argumentos reais que o servidor
recebeu, nunca de texto que a LLM controla.
## Arquitetura
```
Cliente MCP remoto (Claude.ai, via Custom Connector)
│ HTTPS (OAuth Google + allowlist -- fora do escopo deste
│ README; ver mcp-oauth-gateway/enquire-mcp-gateway)
▼
┌───────────────────────────────────────────────────────────┐
│ gateway │
│ │
│ StepUpMiddleware -- por tool call: │
│ 1. policy.yaml decide o nivel (0/1/2) da tool │
│ 2. L0 (tools de auth) -- sempre passa │
│ 3. L1 (leitura) -- exige handle de sessao valido │
│ (senao devolve AUTH_REQUIRED + URL de unlock) │
│ 4. L2 (escrita/delete) -- exige confirmacao fresca │
│ por chamada (args_digest HMAC liga a aprovacao aos │
│ argumentos EXATOS; senao devolve CONFIRMATION_REQUIRED) │
│ │
│ Tools injetadas (nivel 0, sempre disponiveis): │
│ vault_auth_unlock / vault_auth_check / vault_auth_status │
└──────────────────────────┬───────────────────────────────────┘
│ Streamable HTTP + bearer
▼
┌───────────────────────────────────────────────────────────┐
│ auth-service │
│ │
│ WebAuthn (passkey) -- registro, challenges de unlock e de │
│ confirmacao, sessoes (SQLite), audit log append-only. │
│ So alcancavel via rotas /internal (X-Gateway-Key) do │
│ gateway, ou pelas telas publicas /unlock, /confirm, │
│ /register (esta ultima so com token de bootstrap). │
└──────────────────────────┬───────────────────────────────────┘
│ nunca fala com o backend
│ diretamente -- so autentica
▼
(o handle/token volta ao Claude via
gateway, que entao repassa a chamada
original ao backend)
│
▼
┌───────────────────────────────────────────────────────────┐
│ backend │
│ enquire-mcp (serve-http, vault Obsidian) │
└───────────────────────────────────────────────────────────┘
```
O `gateway` nunca guarda credencial nenhuma -- so fala com o `auth-service`
(rotas internas, autenticadas por `GATEWAY_KEY`) para perguntar "este
handle autoriza esta tool?" ou "esta confirmacao aprovou exatamente estes
argumentos?". O humano nunca digita nem cola nada no chat: toda a
ceremonia de passkey acontece no navegador, numa URL que o `auth-service`
serve.
## Niveis de risco
| Nivel | O que exige | Exemplo |
|---|---|---|
| **L0** | Nada -- sempre liberado | `vault_auth_unlock`, `vault_auth_check`, `vault_auth_status` |
| **L1** | Handle de sessao valido (TTL absoluto 15 min, idle 5 min) | `obsidian_search`, `obsidian_read_note`, `obsidian_list_notes` |
| **L2** | Confirmacao com passkey **por chamada**, ligada aos argumentos exatos via `args_digest` (HMAC-SHA256) | `obsidian_create_note`, `obsidian_append_to_note`, `obsidian_archive_note` |
`policies/policy.yaml` mapeia cada tool do backend para um nivel.
**Deny-by-default:** qualquer tool nao mapeada explicitamente cai no nivel
mais restritivo (`default_level: 2`) -- se o `enquire-mcp` ganhar uma tool
nova numa atualizacao (o backend roda `npx -y`, entao pode mudar de versao
a qualquer subida), ela chega **protegida**, nao aberta. Ver os comentarios
no proprio `policies/policy.yaml` para a proveniencia dos nomes de tool
usados e o que ainda precisa ser verificado ao vivo antes de producao.
## Setup
Requer Docker e Docker Compose. Os tres servicos (`gateway`, `auth-service`,
`backend`) sobem juntos.
### 1. Variaveis de ambiente
```bash
cp .env.example .env # Windows: Copy-Item .env.example .env
```
Preencha, na raiz do repo:
- **Google OAuth** (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,
`PUBLIC_BASE_URL`, `ALLOWED_EMAILS`) -- mesmo padrao do
`mcp-oauth-gateway`; veja o README daquele projeto para o passo a passo
de criar o OAuth Client no Google Cloud Console.
- **WebAuthn** (`WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME`, `PUBLIC_ORIGIN`,
`GATEWAY_KEY`, `DIGEST_KEY`) -- ver a advertencia abaixo antes de definir
`WEBAUTHN_RP_ID`. Gere `GATEWAY_KEY` e `DIGEST_KEY` com
`openssl rand -hex 32`.
- **Backend** (`BACKEND_BEARER_TOKEN`, `OBSIDIAN_VAULT_PATH`) -- o token
compartilhado entre `gateway` e `backend`, e o caminho no host do vault
Obsidian a proteger.
> **`WEBAUTHN_RP_ID` e PERMANENTE.** E o dominio (sem porta, sem protocolo)
> que fica embutido na propria assinatura WebAuthn de cada passkey
> registrada. Mudar esse valor depois do primeiro registro **invalida
> TODAS as passkeys** -- todo mundo precisa registrar de novo, com um novo
> bootstrap. Decida o dominio definitivo (o mesmo host que
> `PUBLIC_BASE_URL`, sem `https://`) antes de registrar a primeira passkey,
> nao depois. O `auth-service` se recusa a subir sem esta variavel definida
> (`src/authsvc/config.py`) -- de proposito: um default silencioso aqui
> seria pior que falhar no boot.
### 2. Subir o stack
```bash
docker compose --env-file .env -f docker/docker-compose.yml up -d --build
docker compose --env-file .env -f docker/docker-compose.yml logs -f auth-service
```
`--env-file .env` **nao e opcional** -- o Docker Compose resolve `${VAR}`
do compose relativo ao diretorio do proprio arquivo (`docker/`), nao da
raiz do repo. Rodar sem essa flag faz `OBSIDIAN_VAULT_PATH` cair num
fallback silencioso (`docker/vault`, vazio) em vez do vault real, sem
nenhum erro visivel. Ver o comentario `Uso:` no topo de
`docker/docker-compose.yml` para o detalhe completo (achado do review da
Task 17).
### 3. Registrar a primeira passkey (bootstrap)
Nos logs do `auth-service`, procure:
```
[bootstrap] token de registro (10 min): <token>
```
Abra `<PUBLIC_BASE_URL>/register?t=<token>` no navegador de um dispositivo
com passkey (celular, ou um gerenciador de senhas compativel) e complete o
registro. O token expira em 10 minutos; se perder o prazo, reinicie o
`auth-service` (`docker compose restart auth-service`) para gerar outro --
isso tambem zera sessoes/challenges pendentes
(`SESSION_PURGE_ON_START=true` por padrao).
**Registre pelo menos duas passkeys** (celular + gerenciador de senhas, por
exemplo) enquanto o token de bootstrap ainda vale -- e a mitigacao deste
projeto para "perdi o dispositivo": nao ha codigo de recuperacao (decisao
deliberada; ver a spec de design, secao de decisoes abertas).
### 4. Conectar como Custom Connector
Em claude.ai -> Settings -> Connectors -> Add custom connector, cole
`<PUBLIC_BASE_URL>/mcp`. Deixe os campos de OAuth Client vazios (registro
dinamico). Depois de logar com uma conta Google presente em
`ALLOWED_EMAILS`, o roteiro completo de verificacao (unlock, leitura,
escrita com confirmacao, e o teste de duas conversas) esta em
`tests/integration/test_e2e_manual.md`.
## Limitações conhecidas
- **A8 -- Pessoa B abrindo a mesma conversa dentro da janela de 15
minutos herda o handle.** Este e o furo real, ja documentado e
**aceito por design**, do modelo de handle: o handle de sessao (L1) nao
esta ligado a identidade de quem esta lendo a conversa naquele momento,
so a conversa onde ele nasceu. Se a conta Claude e compartilhada e a
Pessoa B abre a **mesma** conversa que a Pessoa A destravou -- nao uma
conversa nova -- dentro dos 15 minutos de TTL absoluto (ou 5 min de
idle), B herda a capacidade de leitura (L1) que A obteve. Mitigado por
TTL curto, idle timeout, e binding adicional ao `Mcp-Session-Id` quando
o cliente o fornece de forma estavel -- mas **nao eliminado**. Escrita
(L2) permanece inatingivel para B em qualquer caso, porque exige uma
assinatura de passkey fresca por chamada. Ver a secao A8 da spec de
design (`docs/superpowers/specs/2026-08-16-mcp-stepup-auth-proxy-design.md`)
para a analise completa de ameaca. **Isto nao e um bug a ser corrigido
silenciosamente** -- e uma limitacao conhecida do modelo de handle
compartilhado por conversa, e o Passo 7 do roteiro em
`tests/integration/test_e2e_manual.md` existe justamente para provar que
o caso **distinto** (conversa nova) esta corretamente bloqueado.
- **Rate limiting nao esta conectado a nenhum caminho de requisicao.** O
modulo `src/authsvc/ratelimit.py` (janela deslizante em memoria, classe
`Janela`) existe e tem testes proprios, mas **nenhuma rota do
`auth-service` nem do `gateway` o instancia ou chama** -- ele nao esta
"wireado". Na pratica, isso significa que a mitigacao de "brute force de
handle" e "varredura sistematica do vault" descrita na secao 20 (Testes
de seguranca) e na secao 14 (Protecao contra prompt injection, item 4) da
spec de design **ainda nao existe em producao**, apesar do codigo base
estar pronto. Isto e uma lacuna real, nao coberta por nenhum outro
controle deste projeto -- `policies/policy.yaml` tem uma secao
`rate_limits` com valores de exemplo (`level_1: { calls: 60, window_s:
300 }`), mas nada no `gateway_main.py` ou no `src/stepup/middleware.py`
atual le esses valores para de fato limitar chamadas. Antes de expor
este gateway a um uso com volume real (nao so um unico usuario
confiavel), conectar `ratelimit.Janela` ao caminho de L1 (e,
idealmente, tambem a tentativas de challenge/confirmacao no
`auth-service`) deveria ser tratado como prioridade, nao como polimento.
- Demais limitacoes estruturais (sem supervisao de processo, segredo
`BACKEND_BEARER_TOKEN` compartilhado sem escopo por chamador, exposicao
publica exige seu proprio tunel) sao as mesmas do `mcp-oauth-gateway`,
do qual este projeto herda a camada de OAuth/allowlist -- ver o README
daquele projeto para os detalhes.
## Testes
```bash
# Windows
.venv\Scripts\pytest.exe -v
# Linux/macOS
.venv/bin/pytest -v
```
Cobrem: a politica de autorizacao (`src/stepup/policy.py`), o middleware de
step-up (niveis, `AUTH_REQUIRED`/`CONFIRMATION_REQUIRED`), o `auth-service`
(WebAuthn, sessoes, challenges, confirmacoes, audit log, digest HMAC), e a
resolucao de configuracao do `docker-compose.yml` (incluindo os dois modos
de erro do `--env-file .env` ausente).
O roteiro ponta a ponta contra um cliente MCP real e uma passkey fisica
**nao** esta nesta suite -- ver `tests/integration/test_e2e_manual.md`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues