OAuth 2.1: obter uma chave de API com o consentimento do usuário
Este documento é a fonte da verdade do contrato OAuth. Aplicações clientes devem implementar exatamente o que está aqui; mudanças no contrato exigem atualizar este arquivo.
Quando este fluxo é para você: se você está construindo uma aplicação que conecta contas de outras pessoas ao DevZap, o OAuth evita pedir que elas colem uma chave manualmente. Se você só vai integrar o seu próprio sistema ao seu próprio tenant, não precisa disto, pegue uma chave pelo painel, como descrito em Primeiros passos.
O
access_tokenque este fluxo devolve é uma chaveten_comum: use-a exatamente como descrito nos outros guias.Os exemplos citam o cliente "Chatbot Abite", que foi a primeira aplicação a usar o fluxo. O contrato vale para qualquer cliente registrado, e o servidor aceita VÁRIOS clientes ao mesmo tempo: além do bloco legado (
OAUTH_CLIENT_IDetc., com nome viaOAUTH_CLIENT_DISPLAY_NAME, default "Chatbot Abite"), apps adicionais entram em blocos numeradosOAUTH_CLIENT_1_ID,OAUTH_CLIENT_1_SECRET_HASH,OAUTH_CLIENT_1_DISPLAY_NAME,OAUTH_CLIENT_1_REDIRECT_URISeOAUTH_CLIENT_1_ALLOWED_SCOPES(numeração contígua a partir de 1). Cada cliente tem sua própria allowlist de redirect e seu nome no consentimento; a chave emitida se chama "nome (OAuth)" do cliente que a pediu, e cada cliente só consegue revogar as próprias chaves.
Visão geral
Authorization Server OAuth 2.1 (Authorization Code + PKCE/S256) que permite ao
app cliente confiável Chatbot Abite obter, com o consentimento do dono do
tenant, uma API key ten_xxx escopada para provisionar/gerenciar instâncias
de WhatsApp, sem nunca ver a senha do usuário (o login e o consentimento são
hospedados no DevZap).
O access_token emitido É uma API key ten_ normal (revogável, escopada). O
OAuth só substitui o antigo "colar a key manualmente" no chatbot.
Pré-requisitos:
- O DevZap precisa estar com
OAUTH_ENABLED=truee o client registrado (ver "Registro do client"). Com a feature desligada,/oauth/*responde 404. - Em produção, todo
redirect_uriregistrado deve ser HTTPS (validado no boot).
Endpoints
GET /oauth/authorize
Inicia o fluxo. O usuário faz login (se preciso) e consente. Parâmetros (query):
| Param | Obrigatório | Valor |
|---|---|---|
response_type |
sim | code |
client_id |
sim | o client_id registrado |
redirect_uri |
sim | match exato com a allowlist (HTTPS) |
scope |
não | default instances:provision |
state |
sim | opaco, devolvido na volta; o cliente DEVE validar |
code_challenge |
sim | base64url(SHA256(code_verifier)) |
code_challenge_method |
sim | S256 (único aceito) |
Comportamento:
client_id/redirect_uriinválidos → página de erro (HTTP 400). Nunca redireciona (anti open-redirect/confused-deputy).- Sem sessão → redireciona para o login do DevZap (
/login?returnTo=...) e volta ao consentimento após o login. - Demais erros (com
redirect_urijá validado) →302pararedirect_uri?error=...&state=...:invalid_request(faltoustate/PKCE, oucode_challenge_method≠S256)unsupported_response_type(response_type≠code)invalid_scope(scope fora da allowlist)
- Consentimento aprovado →
302pararedirect_uri?code=...&state=.... - Cancelado →
302pararedirect_uri?error=access_denied&state=....
O usuário precisa ser admin (org_admin/platform_admin) do workspace
escolhido; se administra mais de um, escolhe na tela.
POST /oauth/token
Content-Type: application/x-www-form-urlencoded. Sem sessão/CSRF: autenticado
pelo client_secret. Credenciais no corpo ou via HTTP Basic
(Authorization: Basic base64(client_id:client_secret)).
Corpo:
| Param | Valor |
|---|---|
grant_type |
authorization_code |
code |
o code recebido no callback |
redirect_uri |
o MESMO usado no authorize (binding) |
client_id |
(se não usar Basic) |
client_secret |
(se não usar Basic) |
code_verifier |
o verifier do PKCE |
Sucesso (200): header Cache-Control: no-store:
{
"access_token": "ten_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"token_type": "bearer",
"scope": "instances:provision",
"tenant_id": "TENANT-XXXX",
"tenant_name": "Empresa Exemplo",
"api_key_id": "TKEY-XXXXXXXXXXXX"
}
O
access_tokenÉ a API keyten_. Use-a no headerAuthorization: Bearer ten_xxxnas rotas/api/v1/*. É retornada uma única vez: guarde com segurança.
Erros (padrão RFC 6749 §5.2), Cache-Control: no-store:
| HTTP | error |
Quando |
|---|---|---|
| 400 | invalid_request |
faltou code/redirect_uri/code_verifier |
| 401 | invalid_client |
client_id/client_secret inválidos (com WWW-Authenticate: Basic) |
| 400 | invalid_grant |
code inválido/expirado/reusado, binding ou PKCE não conferem |
| 400 | unsupported_grant_type |
grant_type ≠ authorization_code |
{ "error": "invalid_grant", "error_description": "..." }
O code é single-use e expira em ≤ 5 minutos. Uma segunda troca do mesmo
code falha com invalid_grant.
POST /oauth/revoke (desconectar)
application/x-www-form-urlencoded, autenticado pelo client_secret (corpo ou
Basic). Corpo: token=<a API key ten_ a revogar>.
Resposta sempre 200 (neutra; não vaza existência da key) quando o client é
válido. client_secret inválido → 401 invalid_client. Após revogar, a key para
de autenticar em /api/v1/*.
PKCE (obrigatório, S256)
- Gere um
code_verifier: 43 a 128 chars do conjunto[A-Za-z0-9-._~](ex.: 32 bytes aleatórios em base64url). code_challenge = base64url(SHA256(code_verifier))(sem padding).- Envie
code_challenge+code_challenge_method=S256noauthorize. - Envie o
code_verifiernotoken. O servidor recusa (invalid_grant) se não bater.
Scopes
- Scope do consentimento (coarse):
instances:provision: é o que o usuário vê e aprova: «provisionar e gerenciar instâncias de WhatsApp (criar instância, ler QR/status, configurar webhook), enviar mensagens e gerenciar agendamentos. Não inclui ler as conversas». - Scopes concretos da key emitida (least-privilege):
instances:read,instances:manage,webhooks:manage,messages:send,schedules:manage,labels:manage. A key nunca recebe*nemmessages:read.
Cada rota da /api/v1 exige um scope, e a chave sem ele recebe 403 SCOPE_DENIED:
| Scope | Rotas |
|---|---|
instances:read |
GET /instances, GET /instances/{id}, GET .../qr, GET .../limits |
instances:manage |
POST /instances, DELETE /instances/{id}, POST .../disconnect; e disparar a conexão pelo GET .../qr (sem ele, o /qr só lê: devolve o QR que já houver ou o status, com connectionStarted: false) |
webhooks:manage |
GET, POST e DELETE .../webhook, POST .../webhook/rotate-secret |
messages:send |
POST .../messages/send, POST .../messages/queue/send |
groups:read |
GET .../groups |
queue:read |
GET .../messages/queue/{idempotencyKey} |
messages:read |
GET .../messages, GET .../messages/{messageId}/media, GET /messages/search, GET /messages/conversation/{phone}, GET /messages/contacts |
labels:read |
GET .../labels, GET .../labels/{labelId}/chats, GET .../chats/{chat}/labels |
labels:manage |
PUT e DELETE .../labels/{labelId}/chats/{chat} |
schedules:read |
GET /schedules, GET /schedules/runs, GET /schedules/{id}, GET /schedules/{id}/runs |
schedules:manage |
POST /schedules, PATCH e DELETE /schedules/{id} |
Mensagens recebidas (conversations:read). O DevZap só guarda o que um número recebe quando um
administrador liga a gravação naquele número, no painel. As rotas de messages:read continuam
abertas a quem tem esse scope, mas só devolvem as recebidas guardadas para a chave com
conversations:read (a chave * do painel tem). Sem ele, a listagem, a busca, a conversa e os
contatos saem só com o que a conta enviou, e pedir direction=in dá 403 SCOPE_DENIED. Nenhum
outro scope traz conversations:read junto.
Os scopes de leitura vêm junto com o scope que gerencia o mesmo recurso: labels:manage dá
labels:read, schedules:manage dá schedules:read, e messages:send dá groups:read e
queue:read. Por isso a key emitida pelo OAuth alcança as leituras sem que elas apareçam na
lista dela. Um scope de leitura sozinho existe para chaves só de consulta, como a de um
assistente de IA.
GET /status só exige uma chave válida. A key emitida por OAuth vale apenas na
/api/v1: nas rotas do painel e de cobrança ela recebe 403 SCOPE_DENIED.
Segurança (resumo para o integrador)
- Guarde o
client_secretsomente no servidor do chatbot. Nunca no browser. redirect_uriHTTPS e exatamente igual ao registrado (match de string).- Gere e valide o
statea cada fluxo (anti-CSRF do lado do cliente). - O
codeé single-use, TTL ≤ 5 min, vinculado a{client_id, redirect_uri, user_id, tenant_id, scope, code_challenge}. - A key emitida é least-privilege e revogável (painel do DevZap ou
/oauth/revoke). - Nunca logue
code,client_secretnem oaccess_token.
Registro do client (lado DevZap)
npm run gen:oauth-client -- https://chatbot.seu-dominio.com/oauth/callback
Desenvolvimento local (sem HTTPS): em dev o
redirect_uripode ser de loopback HTTP, ex.:npm run gen:oauth-client -- http://localhost:5173/admin/integrations/devzap/oauth/callback(ouhttp://127.0.0.1:5173/...). Fora de loopback o HTTPS continua obrigatório e, em produção,OAUTH_REDIRECT_URISé validado como HTTPS no boot.
O comando imprime as linhas para o .env do DevZap (com o hash bcrypt do
secret) e, uma única vez, o client_secret em claro, que vai na configuração do
Chatbot Abite. Variáveis:
OAUTH_ENABLED=true
OAUTH_CLIENT_ID=abite_xxxxxxxxxxxxxxxx
OAUTH_CLIENT_SECRET_HASH=$2b$10$...
OAUTH_REDIRECT_URIS=https://chatbot.seu-dominio.com/oauth/callback
OAUTH_ALLOWED_SCOPES=instances:provision
# opcional: OAUTH_CODE_TTL_SECONDS=300 (teto 300)
OAUTH_REDIRECT_URIS aceita lista separada por vírgula. Vários ambientes →
vários URIs (todos HTTPS em produção).
Exemplo de fluxo (curl)
# 1) Browser do dono do tenant abre /oauth/authorize?... → login → consente →
# redireciona para: https://chatbot/oauth/callback?code=CODE&state=STATE
# 2) Troca server-to-server do code pela API key:
curl -s -X POST https://devzap.seu-dominio.com/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=authorization_code \
-d code=CODE \
-d redirect_uri=https://chatbot.seu-dominio.com/oauth/callback \
-d client_id=abite_xxx \
-d client_secret=SEGREDO \
-d code_verifier=VERIFIER
# → { "access_token": "ten_...", "token_type": "bearer", ... }
# 3) Usar a key:
curl -s https://devzap.seu-dominio.com/api/v1/instances \
-H 'Authorization: Bearer ten_...'
# 4) Desconectar:
curl -s -X POST https://devzap.seu-dominio.com/oauth/revoke \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d client_id=abite_xxx -d client_secret=SEGREDO -d token=ten_...