Autenticação
Como autenticar requisições no Hub API: JWT HS256 de 15 minutos, refresh token de 7 dias com rotation e reuse detection, e Bearer interno para tráfego server-to-server.
Autenticação
O Hub API protege todas as rotas sob https://hub.simplafy.com.br/api/v1 com JWT HS256. Tokens de acesso vivem por 15 minutos e são renovados via refresh token rotativo (7 dias, com detecção de reuso). Para tráfego interno entre serviços, existe um caminho separado por Bearer secret.
Esta página descreve o fluxo end-to-end usado pelo portal web, pelo cliente HTTP (@simplafy/api-client) e por chamadas server-to-server.
Visão geral
| Componente | Detalhe |
|---|---|
| Algoritmo | HS256 (assinatura simétrica, biblioteca jose) |
| Access token | JWT, expira em 900 segundos (15 min), enviado via header |
| Refresh token | Opaco (UUID v4), expira em 7 dias, enviado via cookie HttpOnly |
| Rotation | A cada /auth/refresh, o token antigo é revogado e um novo é emitido |
| Reuse detection | Reuso de refresh revogado invalida todos os tokens do usuário |
| Cookie | refresh_token — HttpOnly, SameSite=Strict, Secure em produção, Path=/api/v1/auth/refresh |
| Auth interna | Header Authorization: Bearer $MCP_INTERNAL_SECRET (comparação timing-safe) |
O cookie refresh_token só é enviado pelo navegador para /api/v1/auth/refresh (path-scoped). Endpoints de negócio nunca recebem o refresh — eles só aceitam o access token via header Authorization.
Claims do access token
interface JWTPayload {
sub: string; // user id
email: string;
orgId: string; // membership atual
role: "OWNER" | "ADMIN" | "MEMBER" | "CLIENT";
permissions: string[]; // resource:action[] resolvido do role
iat: number;
exp: number;
}O array permissions é resolvido no momento do login/refresh a partir do role (ou de Permission rows quando role = CLIENT). Isso elimina queries de permissão por request — o middleware withAuth lê o claim direto do JWT.
Login (POST /auth/login)
Troca email e senha por um access token e seta o cookie de refresh.
Endpoint: POST https://hub.simplafy.com.br/api/v1/auth/login
Request
{
"email": "[email protected]",
"password": "minha-senha-com-pelo-menos-8-chars"
}Schema (Zod):
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});O email é normalizado (toLowerCase().trim()) antes da busca.
Response 200
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresAt": 1716998400,
"user": {
"id": "clx...",
"email": "[email protected]",
"name": "User Name",
"language": "pt-BR"
},
"org": {
"id": "clo...",
"name": "Minha Organização",
"role": "ADMIN"
}
}expiresAt é um epoch em segundos (sempre now + 900). O cookie refresh_token é setado no mesmo response.
Exemplo
curl -X POST https://hub.simplafy.com.br/api/v1/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"email":"[email protected]","password":"sua-senha"}'const res = await fetch("https://hub.simplafy.com.br/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // preserva o cookie refresh_token
body: JSON.stringify({ email, password }),
});
const { accessToken, expiresAt, user, org } = await res.json();import httpx
with httpx.Client() as client:
r = client.post(
"https://hub.simplafy.com.br/api/v1/auth/login",
json={"email": "[email protected]", "password": "sua-senha"},
)
r.raise_for_status()
data = r.json()
access_token = data["accessToken"]
# cookies do refresh ficam em client.cookiesRefresh token (rotation)
Endpoint usado pelo cliente quando o access token está perto de expirar (ou já retornou 401).
Endpoint: POST https://hub.simplafy.com.br/api/v1/auth/refresh
O refresh token vem exclusivamente do cookie refresh_token. Não há body nem header customizado. O servidor:
Lê refresh_token do cookie. Sem cookie → 401 No refresh token.
Busca o registro em RefreshToken. Se não existe → 401 Invalid or reused refresh token + apaga o cookie.
Se o token está revogado (revokedAt != null), o servidor entende como reuso e revoga todos os refresh tokens daquele usuário. Retorna 401.
Se o token expirou (expiresAt < now), revoga e retorna 401.
Em uma transação Prisma, marca o token atual como revogado (com replacedBy = novo) e cria um novo refresh token. Emite um novo access token e seta o novo cookie.
Response 200
{
"accessToken": "eyJhbGciOi...",
"expiresAt": 1716999300
}Reuse detection é estrito. Se um cliente legítimo perder a corrida com um atacante e enviar o refresh já rotacionado, ambos são deslogados. O cliente deve guardar apenas o refresh mais recente em cookie e nunca persisti-lo fora do navegador.
Exemplo
curl -X POST https://hub.simplafy.com.br/api/v1/auth/refresh \
-b cookies.txt \
-c cookies.txtNo browser, basta fetch(url, { method: "POST", credentials: "include" }) — o navegador anexa o cookie automaticamente porque o path bate.
Headers de auth
Endpoints sob /api/v1/* (exceto /auth/login, /auth/register, /auth/refresh, /auth/forgot-password, /auth/reset-password) exigem:
Authorization: Bearer <accessToken>Exemplo de chamada autenticada:
curl https://hub.simplafy.com.br/api/v1/auth/me \
-H "Authorization: Bearer eyJhbGciOi..."O middleware withAuth decodifica o JWT, valida assinatura e expiração, e injeta um objeto auth no handler:
{
userId: string;
email: string;
orgId: string;
role: "OWNER" | "ADMIN" | "MEMBER" | "CLIENT";
permissions: string[];
}Quando o access expira, o cliente deve chamar /auth/refresh e retentar a request original com o novo token. O SDK @simplafy/api-client já faz isso via interceptor do Axios.
Bearer internal admin
Para chamadas server-to-server entre os apps do monorepo (web → api, mcp-admin → api, proxies internos), existe um caminho de bypass que não usa JWT de usuário.
Como funciona:
Authorization: Bearer <MCP_INTERNAL_SECRET>A validação é feita por validateInternalAuth em comparação timing-safe (Web Crypto API):
const expected = process.env.MCP_INTERNAL_SECRET;
if (!timingSafeCompare(tokenFromHeader, expected)) {
// 401
}O header legado x-internal-admin: true foi removido. Headers controláveis pelo cliente nunca devem dar privilégio — qualquer integração que ainda dependa dele precisa migrar para Authorization: Bearer $MCP_INTERNAL_SECRET.
Onde usar:
- Apenas em chamadas dentro do cluster (
simplafy-prd-hub,simplafy-staging-hub). - Nunca expor
MCP_INTERNAL_SECRETem frontend, mobile, ou cliente público. - Rotacionar via Infisical (
/web,/mcpno projeto Hub v1) — após rotação, todos os pods precisam reler o secret.
Erros de auth
| Status | Cenário | Body |
|---|---|---|
400 | Payload de login inválido (Zod) | { "error": "...validation message..." } |
401 | Credenciais inválidas no login | { "error": "Invalid credentials" } |
401 | Access token ausente, expirado ou assinatura inválida | { "error": "Unauthorized" } |
401 | Cookie refresh_token ausente em /auth/refresh | { "error": "No refresh token" } |
401 | Refresh token inválido, expirado ou reusado | { "error": "Invalid or reused refresh token" } |
401 | Bearer interno ausente, mal formado ou diferente do secret | { "error": "..." } |
403 | Login sem membership de organização | { "error": "User has no organization membership" } |
500 | Erro interno (logado no servidor + GlitchTip) | { "error": "Internal server error" } |
Boas práticas
- Trate
401como dois cenários distintos no cliente: token expirado (retentar via/auth/refresh) vs refresh inválido (forçar logout e voltar para tela de login). - Não cache o access token além do necessário — ele já é curto (15 min). Renove proativamente ~1 min antes de
expiresAt. - Sempre envie
credentials: "include"em chamadas para/auth/refreshno browser; sem isso o cookie não trafega. - Logout completo: chame
POST /api/v1/auth/logout(com access token) — revoga todos os refresh tokens do usuário e apaga o cookie.