mcp-stepup-gateway
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. 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:
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.
"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.
Related MCP server: Obsidian MCP Wrapper
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 |
|
L1 | Handle de sessao valido (TTL absoluto 15 min, idle 5 min) |
|
L2 | Confirmacao com passkey por chamada, ligada aos argumentos exatos via |
|
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
cp .env.example .env # Windows: Copy-Item .env.example .envPreencha, na raiz do repo:
Google OAuth (
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,PUBLIC_BASE_URL,ALLOWED_EMAILS) -- mesmo padrao domcp-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 definirWEBAUTHN_RP_ID. GereGATEWAY_KEYeDIGEST_KEYcomopenssl rand -hex 32.Backend (
BACKEND_BEARER_TOKEN,OBSIDIAN_VAULT_PATH) -- o token compartilhado entregatewayebackend, e o caminho no host do vault Obsidian a proteger.
WEBAUTHN_RP_IDe 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 quePUBLIC_BASE_URL, semhttps://) antes de registrar a primeira passkey, nao depois. Oauth-servicese 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
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-Idquando 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 emtests/integration/test_e2e_manual.mdexiste 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, classeJanela) existe e tem testes proprios, mas nenhuma rota doauth-servicenem dogatewayo 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.yamltem uma secaorate_limitscom valores de exemplo (level_1: { calls: 60, window_s: 300 }), mas nada nogateway_main.pyou nosrc/stepup/middleware.pyatual 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), conectarratelimit.Janelaao caminho de L1 (e, idealmente, tambem a tentativas de challenge/confirmacao noauth-service) deveria ser tratado como prioridade, nao como polimento.Demais limitacoes estruturais (sem supervisao de processo, segredo
BACKEND_BEARER_TOKENcompartilhado sem escopo por chamador, exposicao publica exige seu proprio tunel) sao as mesmas domcp-oauth-gateway, do qual este projeto herda a camada de OAuth/allowlist -- ver o README daquele projeto para os detalhes.
Testes
# Windows
.venv\Scripts\pytest.exe -v
# Linux/macOS
.venv/bin/pytest -vCobrem: 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
Related MCP Connectors
- gatewayOAuthai.sealgate
MCP gateway with runtime security policy, tool-call-level control, and audit of agent actions.
- JustOnceOAuthai.justonce
Persistent memory for AI assistants — one shared, OAuth-secured vault for every MCP client.
Zero-setup MCP gateway securely connecting AI to your tools with authentication and workflows
Remote MCP for Copilot CLI switch gate MCP, structured receipts, audit logs, and reviewer-ready evid
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceA deny-by-default MCP server for Obsidian vaults where operators declare exact capabilities (list, read, create, etc.) scoped by path globs; everything not permitted is impossible by construction as disallowed tools are never registered.MIT
- FlicenseDqualityDmaintenanceEnables Claude Desktop to securely search and retrieve knowledge from an Obsidian vault through a stateless MCP interface, with progressive disclosure and gated write capabilities.13-
- AlicenseAqualityCmaintenanceSecure MCP server that bridges AI clients like Claude Desktop to Obsidian vaults, enabling read/write operations with OWASP Top 10 security controls and audit logging.948 npmMIT
- FlicenseNot gradedqualityBmaintenanceRemote MCP server for Obsidian vault access, giving Claude read/search/archive access to markdown notes via OAuth 2.1 + PKCE auth.2-