Skip to main content
Glama
joaorura

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