Entrar com Asender (apps de terceiro)
Como um app de terceiro autentica usuários Asender com 1 clique e lê dados da conta com escopos: authorization code + PKCE, consentimento, refresh com rotação, revogação, erros e sincronização incremental com a API pública.
Entrar com Asender
"Entrar com Asender" deixa o seu app (site, SaaS, integração) identificar um usuário Asender e, com o consentimento dele, ler dados de uma conta Asender pela API pública, com escopos mínimos. O usuário clica no botão e, se já estiver logado na Asender e já tiver autorizado o app antes, volta para o seu app em um redirect, sem tela intermediária.
- Servidor de autorização:
https://auth.asender.net(OIDC / OAuth 2.1) - API de recursos:
https://api.asender.net/v1 - Perfil: Authorization Code + PKCE
S256obrigatório, client confidencial (client_secret_basic) ou público (sem segredo),redirect_uriexata.
Este guia é para apps de terceiro. Para o login OIDC genérico (fronts e integrações próprias), veja o guia de Identidade. Os valores aqui vêm do código do servidor de autorização; confira sempre o discovery (/.well-known/openid-configuration) antes de cravar um endereço.
1. Como o seu app é cadastrado
Você mesmo cadastra o app no painel, em https://admin.asender.net, na conta que será a dona dele: Desenvolvedores > Aplicativos (só owner e admin da conta). O guia de Aplicativos descreve cada campo, os limites e o ciclo de vida. Ao criar, você recebe:
client_id;client_secret, só para app confidencial, mostrado uma única vez (se perder, gire o segredo no painel: o antigo deixa de valer na hora).
Para o cadastro, informe: nome do app, site (site_url), logo, os redirect_uri exatos (sem curinga) e os escopos de que precisa. O client_secret fica só no seu backend (cofre ou variável de ambiente do servidor), nunca no navegador, em app mobile, repositório ou log. Apps que não conseguem guardar segredo (SPA, mobile, CLI) escolhem o tipo público: sem client_secret, só PKCE.
Publicação e revisão. O app nasce em desenvolvimento: só membros da sua conta e testadores convidados conseguem autorizá-lo, o que basta para integrar e testar. Para abri-lo a qualquer usuário Asender, peça revisão no painel; a equipe Asender avalia e, aprovado, o app vira publicado e verificado. Até lá, usuários de fora recebem o aviso de indisponível. Detalhes em Aplicativos.
O app só consegue pedir os escopos declarados no cadastro (e, depois de publicado, os aprovados na revisão). Pedir além disso retorna invalid_scope.
2. O fluxo, passo a passo
2.1 Descubra os endpoints
curl -s https://auth.asender.net/.well-known/openid-configuration
Campos que importam: authorization_endpoint (/authorize), token_endpoint (/token), revocation_endpoint (/oauth/revoke), introspection_endpoint (/oauth/introspect), jwks_uri, scopes_supported, code_challenge_methods_supported (S256), authorization_response_iss_parameter_supported (true) e prompt_values_supported (none, login, consent).
2.2 Gere state, nonce e o par PKCE (no backend)
Use um gerador criptográfico (CSPRNG), nunca Math.random().
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
Guarde CODE_VERIFIER, STATE e NONCE no servidor, ligados à sessão do navegador do usuário (cookie de sessão do seu app, HttpOnly, Secure, SameSite=Lax). O code_verifier nunca vai na URL.
2.3 Mande o usuário para /authorize
GET https://auth.asender.net/authorize
?response_type=code
&client_id=<CLIENT_ID>
&redirect_uri=https%3A%2F%2Fapp.exemplo.com%2Fcallback
&scope=openid%20profile%20email%20offline_access%20leads%3Aread%20conversions%3Aread
&state=<STATE>
&nonce=<NONCE>
&code_challenge=<CODE_CHALLENGE>
&code_challenge_method=S256
| parâmetro | obrigatório | nota |
|---|---|---|
response_type | sim | sempre code |
client_id | sim | o seu |
redirect_uri | sim | igual, caractere a caractere, a uma cadastrada |
scope | sim | separado por espaço; openid é sempre incluído; veja o catálogo na seção 4 |
state | sim | anti-CSRF; volta igual no redirect |
nonce | recomendado | volta no ID token; confira |
code_challenge / code_challenge_method | sim | somente S256 |
prompt | não | none, login ou consent (veja abaixo) |
O que o usuário vê:
- Sem sessão Asender: vai ao login (com 2º fator, passkey etc.) e volta.
- Primeiro acesso do seu app (ou escopos novos): tela de consentimento mostrando o seu app, os escopos em português e a conta em que o app vai operar. Ele aprova ou nega.
- Já autorizou antes e a sessão está viva: redirect imediato (o "1 clique").
prompt:
none: nunca mostra tela. Se faltar login, voltaerror=login_required; se faltar consentimento,error=consent_required. Não combine com outros valores.consent: força a tela de consentimento, mesmo com consentimento lembrado (use para trocar de conta ou ampliar escopos).login: força um novo login.
2.4 Receba o redirect e confira iss e state
Sucesso:
https://app.exemplo.com/callback?code=<CODE>&state=<STATE>&iss=https%3A%2F%2Fauth.asender.net
Erro:
https://app.exemplo.com/callback?error=access_denied&error_description=...&state=<STATE>&iss=https%3A%2F%2Fauth.asender.net
Antes de qualquer coisa, no seu backend:
stateigual ao guardado (e descarte-o: uso único);issigual ahttps://auth.asender.net. O servidor publicaauthorization_response_iss_parameter_supported(RFC 9207). Conferir impede o ataque de mix-up, em que um servidor malicioso faz o seu app enviar o code (e ocode_verifier) para o lugar errado. Seissfaltar ou divergir, aborte sem trocar o code.
O code vale 60 segundos e é de uso único.
2.5 Troque o code em /token
Autentique com client_secret_basic (Basic de client_id:client_secret), somente do seu backend:
curl -s https://auth.asender.net/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri="https://app.exemplo.com/callback" \
-d code_verifier="$CODE_VERIFIER"
A resposta traz access_token, id_token, token_type, expires_in e, se offline_access foi concedido, refresh_token. Valide o ID token (assinatura pelo JWKS, iss, aud igual ao seu client_id, exp e o nonce guardado).
2.6 O access token
JWT RS256. Claims do token de app de terceiro:
| claim | significado |
|---|---|
iss | https://auth.asender.net |
sub | id público do usuário (imutável; nunca use e-mail como id) |
aud | https://api.asender.net (o recurso, não o seu client) |
azp | o seu client_id |
tenant | a conta escolhida pelo usuário (acc_...) |
scope | escopos efetivos, separados por espaço |
ext | true: token de terceiro |
jti, iat, exp | id, emissão e expiração (15 minutos) |
Trate o token como string opaca e envie no header; leia scope e tenant só para decidir o que mostrar. Quem autoriza é a API.
curl -s https://api.asender.net/v1/account -H "Authorization: Bearer $ACCESS_TOKEN"
A API cruza três coisas: o escopo do token, a conta (tenant) e o que o próprio usuário pode ver naquela conta. O escopo só restringe, nunca amplia: se o usuário sair da conta ou perder o papel, o app perde o acesso em até cerca de um minuto.
Um token de app de terceiro não vale como sessão do usuário em nenhuma outra parte da Asender (painel, APIs internas das ferramentas). Só serve na API pública, dentro dos escopos.
3. Refresh, revogação e introspecção
Refresh com rotação
O access token dura 15 min. Para continuar sem o usuário presente, peça offline_access e use o refresh token (a validade é definida por app no cadastro; para o owiew, 90 dias). O refresh fica preso ao consentimento, não ao navegador: o app segue sincronizando depois que o usuário fecha a aba.
curl -s https://auth.asender.net/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"
Regras:
- Cada refresh é de uso único. A resposta traz um refresh token novo: grave-o antes de usar o novo access token (transação atômica no seu banco).
- Reuso derruba tudo. Apresentar um refresh já usado revoga todos os refresh daquele consentimento, e o app precisa pedir autorização de novo. Por isso serialize os refreshes (um por vez por conta conectada, com lock) e não reenvie um refresh por retry cego depois de um timeout sem saber se o primeiro foi aplicado: se houver dúvida, refaça o login.
- Antes de cada refresh o servidor reconfere se o consentimento continua vigente e se o usuário ainda pertence à conta. Se não,
invalid_grant.
Revogar (RFC 7009)
curl -s https://auth.asender.net/oauth/revoke \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$REFRESH_TOKEN" -d token_type_hint=refresh_token
Use ao desconectar a conta no seu app. O usuário também revoga em "Apps conectados", na conta Asender; nesse caso o próximo refresh retorna invalid_grant: marque a conexão como encerrada e peça para reconectar.
Introspecção (RFC 7662)
Somente para client confidencial autenticado:
curl -s https://auth.asender.net/oauth/introspect \
-u "$CLIENT_ID:$CLIENT_SECRET" -d token="$TOKEN"
Devolve {"active": false} para token inválido, expirado ou revogado; se ativo, traz scope, client_id, sub, exp, iat, iss, aud, tenant e jti. Use pontualmente (diagnóstico), não a cada chamada de dados.
4. Catálogo de escopos
Peça o mínimo necessário. O usuário vê estes rótulos na tela de consentimento.
| escopo | descrição |
|---|---|
openid | Confirmar quem é o usuário (necessário para entrar). |
profile | Ver o nome do perfil. |
email | Ver o e-mail principal. |
offline_access | Continuar conectado em segundo plano (emite refresh token). |
account:read | Nome e dados cadastrais da conta escolhida. |
leads:read | Contatos do CRM e envios de formulário da conta. |
conversions:read | Eventos de conversão e entregas para Meta, Google e TikTok. |
forms:read | Formulários da conta e suas configurações. |
analytics:read | Métricas de páginas e formulários. |
campaigns:read | Campanhas de e-mail marketing e relatórios. |
email:read | Mensagens e eventos de e-mail da conta. |
messaging:read | Mensagens e relatórios de SMS, WhatsApp e push. |
conversations:read | Conversas de atendimento. |
journeys:read | Jornadas de automação. |
logs:read | Registros de chamadas e eventos da conta. |
webhooks:manage | Criar e remover webhooks da conta (planejado). |
leads:write | Criar e atualizar contatos e leads (planejado). |
A lista viva está em scopes_supported do discovery. O escopo efetivo do token é pedido, limitado ao liberado para o seu app e ao concedido pelo usuário; confira o claim scope do que realmente veio.
5. O botão e o "1 clique"
Faça o botão apontar para o seu endpoint (/auth/asender/login), que gera state/nonce/PKCE, grava na sessão e responde 302 para o /authorize. O botão nunca monta o /authorize no navegador: o code_verifier e o client_secret não podem passar por lá.
<a class="btn-asender" href="/auth/asender/login">Entrar com Asender</a>
Para a experiência de 1 clique:
- No primeiro login, use o fluxo normal, sem
prompt: ele mostra login e consentimento quando necessário, e o consentimento fica lembrado. - Se a sessão Asender estiver viva e o consentimento lembrado cobrir os escopos pedidos, o
/authorizeresponde direto com o code, sem nenhuma tela. - Para tentar uma entrada silenciosa (por exemplo, ao abrir o app), use
prompt=nonee tratelogin_required/consent_requiredmostrando o botão normal. - Pedir um escopo novo depois exige nova aprovação do usuário (ele vê a tela).
6. Escolha de conta
Um token = um usuário + uma conta. A conta é escolhida na tela de consentimento (entre as contas onde o usuário tem permissão) e vai no claim tenant. Para operar outra conta do mesmo usuário, faça um novo /authorize com prompt=consent, que reabre a escolha, e guarde a nova conexão como outra conta no seu app (sub + tenant identificam a conexão). Nunca aceite tenant vindo do cliente: use sempre o da conexão que você gravou.
7. Erros
No redirect do /authorize (query: error, error_description, state, iss):
error | quando | o que fazer |
|---|---|---|
invalid_scope | escopo desconhecido ou não liberado ao app | corrija o pedido; peça liberação ao time Asender |
access_denied | o usuário negou | respeite; não insista em loop |
login_required | prompt=none sem sessão | mostre o botão e reinicie sem prompt=none |
consent_required | prompt=none sem consentimento que cubra os escopos | idem |
invalid_request | parâmetro faltando ou inválido; prompt=none combinado com outro valor | corrija |
temporarily_unavailable / server_error | falha momentânea | tente de novo depois, com espera |
Se o redirect_uri ou o client_id forem inválidos, o servidor não redireciona (mostra o erro ao usuário): isso é erro de cadastro.
No /token, /oauth/revoke e /oauth/introspect (JSON com error e error_description):
| HTTP | error | causa comum |
|---|---|---|
| 400 | invalid_grant | code expirado ou já usado; redirect_uri ou PKCE não conferem; refresh inválido, expirado, reusado ou revogado; usuário revogou o app ou saiu da conta |
| 400 | invalid_request / unsupported_grant_type | corpo malformado, grant não suportado |
| 401 | invalid_client | client_id/client_secret errados, ou introspecção por client que não é confidencial |
| 5xx | server_error | falha; refaça com espera (em 503 na conferência do acesso o refresh não é consumido) |
invalid_grant no refresh significa: encerre a conexão e peça ao usuário para entrar de novo.
8. Segurança (checklist)
client_secrete refresh tokens só no backend, cifrados em repouso; nunca emNEXT_PUBLIC_*, bundle, app mobile ou repositório.redirect_uriexata e fixa; sem redirect aberto no seu callback.state(CSRF),nonce(replay do ID token) e PKCES256comcode_verifiergerado por CSPRNG, todos por tentativa e de uso único.- Confira
issna volta e no ID token. - Nunca coloque token em URL, query, log, mensagem de erro, analytics ou rastreador de erros. Redija o header
Authorizationnos logs. - HTTPS sempre; valide o ID token antes de confiar no
sub. - Identifique o usuário por
sub, não por e-mail (ele pode ter vários). - Ao receber
invalid_grantno refresh, pare de tentar e peça nova autorização.
9. Lendo dados: a API pública
Base: https://api.asender.net/v1, com Authorization: Bearer <access_token>. A conta vem do token; não há header nem parâmetro de conta.
Convenções das rotas de dados
- Somente leitura (
GET), JSON emsnake_case. - Lista:
{"data": [...], "next_cursor": "<opaco>" | null}. limit: de 1 a 200 (padrão 50).cursor: onext_cursorda página anterior, opaco e estável; não monte nem interprete.- Incremental:
updated_after(RFC 3339) onde o recurso é alterável, ordenado por(updated_at, id);created_afteronde o recurso é imutável. - Erros:
{"error": {"type": "...", "message": "..."}}. - Token ausente, inválido ou expirado:
401. Escopo ou acesso insuficiente: erro de autorização (403).
Sincronização incremental (boas práticas)
- Carga inicial: percorra a lista seguindo
next_cursoraténull, comlimit=200. Anote o instante de início da carga (T0) antes da primeira página. - Rodadas seguintes: peça
updated_after=<marca anterior>e percorra os cursores. Ao terminar com sucesso, grave como nova marca oT0daquela rodada (não o fim), menos uma pequena folga (por exemplo, 60 s) para não perder escritas simultâneas. - Idempotência: faça upsert pelo
iddo recurso; a folga e os retries geram repetição, e isso é esperado. - Não recomece do zero por erro transitório: retome do último
cursorgravado. - Espace as rodadas (minutos, não segundos) e não faça varredura completa recorrente.
- Rate limit: o limite é por app e conta. Em
429, espere o tempo do headerRetry-After(segundos) antes de tentar de novo; se não vier, use backoff exponencial com jitter. Não paralelize além do necessário. Em5xx, tente de novo com espera crescente. - Em
401, renove o access token (um único refresh, serializado) e repita a chamada uma vez.
Rotas por escopo
Todas as rotas abaixo estão disponíveis e aceitam o token de app (e também a chave sk_* com o mesmo escopo). Os campos completos de cada resposta estão na OpenAPI pública (/v1/..., esquema OAuthBearer); a tabela traz o que sincronizar.
| escopo | rota | produto exigido do usuário | incremental | campos principais |
|---|---|---|---|---|
account:read | GET /v1/account | (só a conta) | não | dados da conta, ClientId |
leads:read | GET /v1/leads, GET /v1/leads/{id} | CRM | updated_after (updated_at) | id (id público do lead), first_name, last_name, email, phone, company, job_title, stage, lifecycle, source (name, utm_*, referrer, domain, form_id), tags, custom_fields, created_at, updated_at |
forms:read | GET /v1/forms | Páginas | não (lista pequena) | id, name, external_id, requires_consent, legal_basis, fields (name, label, type, required, options, key) |
forms:read | GET /v1/forms/{id}/submissions | Páginas | created_after (created_at) | id, form_id, mode, fields (em claro), source (utm_*, referrer), consent (state, legal_basis, at), created_at |
conversions:read | GET /v1/conversions | CRM | created_after (created_at) | id, lead_id, type (sent, ignored, result), event, value, currency, platform, delivery_status, reason, source, occurred_at, created_at |
campaigns:read | GET /v1/campaigns | E-mail Marketing | updated_after | id, name, status, subject, recipients, sent, failed, scheduled_at, updated_at |
campaigns:read | GET /v1/campaigns/{id} | E-mail Marketing | n/a | o mesmo objeto da campanha, sem envelope |
campaigns:read | GET /v1/campaigns/{id}/report | E-mail Marketing | n/a | data.totals (enviados, entregues, aberturas, cliques, bounces) |
campaigns:read | GET /v1/reports/email/overview, /timeseries, /top-emails, /domains | E-mail Marketing | janela days ou from+to/period | data com totals, previous, points |
email:read | GET /v1/emails, GET /v1/emails/{id} | Marketing | created_after | formato legado (PascalCase) |
messaging:read | GET /v1/messaging/sms/messages, /whatsapp/messages, /push/messages | SMS, WhatsApp ou Push | created_after (ordem crescente) | id, to, status, queued_at, created_at (+ segments no SMS, title no push) |
messaging:read | GET /v1/messaging/{sms,whatsapp,push}/reports/overview | idem | janela days | data com totais por status |
analytics:read | GET /v1/analytics/overview, /timeseries, /breakdown, /forms | Páginas | janela from/to (até 92 dias) | totals, data[] (visitas, visualizações, conversões, funil de formulário) |
conversations:read | GET /v1/conversations | Atendimento | updated_after (atividade, inclusive mensagem nova) | id, channel, status, lead_id, assigned_to, updated_at, message_count |
conversations:read | GET /v1/conversations/{id}/messages | Atendimento | created_after | id, direction, author_type, text, delivery_status, sent_at, attachment_count |
journeys:read | GET /v1/journeys, GET /v1/journeys/{id} | CRM | updated_after | id, name, active, trigger, steps, stats, updated_at |
webhooks:manage, leads:write | gestão e escrita | planejado |
Pontos que valem para todas:
- Nomes em inglês,
snake_case, em todas as rotas de dados. As chaves que pertencem à conta (custom_fieldsdo lead efieldsde um envio) vêm como a conta as definiu e não são traduzidas. Valores de enumeração (comostage) são os da conta. - Mesmo identificador de lead.
lead_idem/v1/conversionse em/v1/conversationsé oiddeGET /v1/leads; dá para ligar conversa, conversão e lead sem tabela de apoio. - Recurso único (
/v1/leads/{id},/v1/campaigns/{id},/v1/journeys/{id}) vem como objeto, sem envelope. Relatórios agregados vêm em{"data": {...}}. - Id inexistente ou de outra conta é sempre
404idêntico: nunca403. - Conta cujo canal de e-mail ou mensageria ainda não migrou para a ferramenta dedicada responde
503comRetry-After(volte depois; o resto da API não é afetado). - Parâmetro de query desconhecido nas rotas de campanhas, relatórios e mensageria é
400(nada é ignorado em silêncio). Janela acima do teto da rota também é400. - Cada rota exige o escopo correspondente e que o usuário que autorizou o app tenha, na conta do token, acesso ao produto da coluna "produto exigido" (
403 Forbiddense não tiver; a remoção do usuário passa a valer em até 60 s).
Exemplo (lista incremental de leads):
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://api.asender.net/v1/leads?limit=200&updated_after=2026-10-01T00:00:00Z"
# {"data":[{"id":"ctt_...","email":"..."}],"next_cursor":"..."}
10. Exemplo mínimo
Um exemplo server-side (Next.js route handlers, TypeScript, PKCE por CSPRNG, sem segredo no código), publicado aqui mesmo. Ele cobre: iniciar o login, callback com conferência de state e iss, troca do code, refresh com rotação e chamada à API. O armazenamento de sessão e de tokens é um ponto marcado para você implementar no seu app. As variáveis de ambiente ficam no servidor; nenhum segredo vai ao navegador.
asender-oauth.ts — o núcleo (PKCE, authorize, troca de code, refresh, API)
/**
* asender-oauth.ts — núcleo do "Entrar com Asender" para um backend Node/Next.js.
*
* O quê: gera state/nonce/PKCE por CSPRNG, monta o /authorize, troca code e
* refresh por tokens e chama a API pública. Só roda no SERVIDOR.
* Onde: usado pelos route handlers de `routes.ts` (login, callback, sincronização).
* Não há segredo no código: tudo vem do ambiente do servidor.
*
* ASENDER_ISSUER https://auth.asender.net
* ASENDER_API https://api.asender.net
* ASENDER_CLIENT_ID recebido no cadastro do app
* ASENDER_CLIENT_SECRET recebido UMA vez no cadastro (cofre do servidor)
* ASENDER_REDIRECT_URI exatamente como cadastrada
*/
import { createHash, randomBytes } from "node:crypto";
export const ESCOPOS = "openid profile email offline_access leads:read conversions:read";
function env(nome: string): string {
const v = process.env[nome];
if (!v) throw new Error(`variável de ambiente ausente: ${nome}`);
return v;
}
/** Valor aleatório URL-safe de `bytes` bytes (CSPRNG do Node). */
function aleatorio(bytes: number): string {
return randomBytes(bytes).toString("base64url");
}
export interface TentativaLogin {
state: string;
nonce: string;
codeVerifier: string;
url: string;
}
/**
* Cria uma tentativa de login: devolve a URL do /authorize e os segredos da
* tentativa, que o chamador grava NA SESSÃO DO SERVIDOR (nunca na URL nem em
* cookie legível por JS). Uso único por tentativa.
*/
export function novaTentativa(opcoes: { prompt?: "none" | "consent" | "login" } = {}): TentativaLogin {
const state = aleatorio(16);
const nonce = aleatorio(16);
const codeVerifier = aleatorio(48); // 64 caracteres, dentro de 43..128
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const q = new URLSearchParams({
response_type: "code",
client_id: env("ASENDER_CLIENT_ID"),
redirect_uri: env("ASENDER_REDIRECT_URI"),
scope: ESCOPOS,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
if (opcoes.prompt) q.set("prompt", opcoes.prompt);
return { state, nonce, codeVerifier, url: `${env("ASENDER_ISSUER")}/authorize?${q}` };
}
/** Basic auth do client confidencial (client_secret_basic). */
function basic(): string {
const par = `${encodeURIComponent(env("ASENDER_CLIENT_ID"))}:${encodeURIComponent(env("ASENDER_CLIENT_SECRET"))}`;
return "Basic " + Buffer.from(par).toString("base64");
}
export interface Tokens {
access_token: string;
id_token?: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}
export class ErroOAuth extends Error {
constructor(public status: number, public codigo: string, mensagem: string) {
super(mensagem);
}
}
/** POST no /token. Nunca registra o corpo da resposta nem os parâmetros. */
async function postToken(corpo: Record<string, string>): Promise<Tokens> {
const resp = await fetch(`${env("ASENDER_ISSUER")}/token`, {
method: "POST",
headers: { Authorization: basic(), "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(corpo),
cache: "no-store",
});
if (!resp.ok) {
const erro = (await resp.json().catch(() => ({}))) as { error?: string };
throw new ErroOAuth(resp.status, erro.error ?? "server_error", "falha no /token");
}
return (await resp.json()) as Tokens;
}
/**
* Valida o redirect do /authorize e troca o code. Confere `error`, `state` e `iss`
* ANTES de usar o code. `esperado` vem da sessão do servidor.
*/
export async function trocarCode(
query: URLSearchParams,
esperado: { state: string; codeVerifier: string },
): Promise<Tokens> {
if (query.get("iss") !== env("ASENDER_ISSUER")) throw new ErroOAuth(400, "iss_invalido", "iss inesperado");
if (query.get("state") !== esperado.state) throw new ErroOAuth(400, "state_invalido", "state inesperado");
const erro = query.get("error");
if (erro) throw new ErroOAuth(400, erro, "autorização recusada"); // access_denied, consent_required...
const code = query.get("code");
if (!code) throw new ErroOAuth(400, "invalid_request", "code ausente");
return postToken({
grant_type: "authorization_code",
code,
redirect_uri: env("ASENDER_REDIRECT_URI"),
code_verifier: esperado.codeVerifier,
});
}
/**
* Renova com o refresh token. O refresh é de USO ÚNICO: o chamador deve gravar o
* novo `refresh_token` na mesma transação, antes de usar o access token, e
* serializar (lock por conexão). `invalid_grant` = conexão encerrada: peça novo login.
*/
export function renovar(refreshToken: string): Promise<Tokens> {
return postToken({ grant_type: "refresh_token", refresh_token: refreshToken });
}
/** Revoga o refresh token (ao desconectar a conta no seu app). */
export async function revogar(refreshToken: string): Promise<void> {
const resp = await fetch(`${env("ASENDER_ISSUER")}/oauth/revoke`, {
method: "POST",
headers: { Authorization: basic(), "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: refreshToken, token_type_hint: "refresh_token" }),
});
if (!resp.ok) throw new ErroOAuth(resp.status, "revoke_falhou", "falha ao revogar");
}
/**
* GET na API pública respeitando 429/Retry-After. Devolve o JSON; em 401 o
* chamador renova o access token UMA vez e repete.
*/
export async function chamarApi<T>(caminho: string, accessToken: string, tentativas = 3): Promise<T> {
for (let i = 0; i < tentativas; i++) {
const resp = await fetch(`${env("ASENDER_API")}${caminho}`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (resp.status === 429 || resp.status >= 500) {
const espera = Number(resp.headers.get("Retry-After")) || 2 ** i + Math.random();
await new Promise((r) => setTimeout(r, espera * 1000));
continue;
}
if (!resp.ok) throw new ErroOAuth(resp.status, "api_erro", `API respondeu ${resp.status}`);
return (await resp.json()) as T;
}
throw new ErroOAuth(503, "api_indisponivel", "API indisponível após retries");
}
export interface Pagina<T> {
data: T[];
next_cursor: string | null;
}
/**
* Percorre uma lista paginada (`data`/`next_cursor`), opcionalmente incremental.
* `aoPagina` grava cada página (upsert por id) e o cursor, para retomar depois.
*/
export async function sincronizar<T>(
caminho: string,
accessToken: string,
aoPagina: (itens: T[], cursor: string | null) => Promise<void>,
opcoes: { updatedAfter?: string; cursor?: string } = {},
): Promise<void> {
let cursor: string | null = opcoes.cursor ?? null;
do {
const q = new URLSearchParams({ limit: "200" });
if (opcoes.updatedAfter) q.set("updated_after", opcoes.updatedAfter);
if (cursor) q.set("cursor", cursor);
const pag = await chamarApi<Pagina<T>>(`${caminho}?${q}`, accessToken);
cursor = pag.next_cursor;
await aoPagina(pag.data, cursor);
} while (cursor);
}
routes.ts — os route handlers de login e callback
/**
* routes.ts — route handlers (Next.js App Router) do "Entrar com Asender".
*
* O quê: `GET /auth/asender/login` inicia o fluxo; `GET /auth/asender/callback`
* conclui. Cada bloco abaixo é um arquivo `route.ts` próprio no seu projeto
* (app/auth/asender/login/route.ts e app/auth/asender/callback/route.ts).
* Onde: usa `asender-oauth.ts`. O armazenamento (`sessao`, `conexoes`) é do SEU app:
* aqui só a interface esperada. Sem segredo real neste arquivo.
*/
import { NextRequest, NextResponse } from "next/server";
import { ErroOAuth, novaTentativa, trocarCode } from "./asender-oauth";
/** Sessão do SEU app, no servidor (cookie de sessão HttpOnly+Secure+SameSite=Lax). */
declare const sessao: {
gravarTentativa(req: NextRequest, t: { state: string; nonce: string; codeVerifier: string }): Promise<void>;
/** Lê E APAGA a tentativa (uso único). */
consumirTentativa(req: NextRequest): Promise<{ state: string; nonce: string; codeVerifier: string } | null>;
};
/** Persistência cifrada em repouso dos tokens por conexão (usuário Asender + conta). */
declare const conexoes: {
salvar(c: { sub: string; tenant: string; refreshToken: string | undefined; scope: string }): Promise<void>;
};
/** Extrai sub/tenant/scope do access token (decodifique o JWT; a API é quem autoriza). */
declare function lerClaims(accessToken: string): { sub: string; tenant: string; scope: string };
// ── app/auth/asender/login/route.ts ─────────────────────────────────────────
export async function GET_login(req: NextRequest): Promise<NextResponse> {
const t = novaTentativa(); // prompt opcional: "none" para tentativa silenciosa
await sessao.gravarTentativa(req, { state: t.state, nonce: t.nonce, codeVerifier: t.codeVerifier });
return NextResponse.redirect(t.url, 302);
}
// ── app/auth/asender/callback/route.ts ──────────────────────────────────────
export async function GET_callback(req: NextRequest): Promise<NextResponse> {
const tentativa = await sessao.consumirTentativa(req);
if (!tentativa) return NextResponse.redirect(new URL("/entrar?erro=sessao", req.url), 302);
try {
const tokens = await trocarCode(req.nextUrl.searchParams, tentativa);
// Valide o id_token aqui (assinatura pelo JWKS, iss, aud, exp e nonce === tentativa.nonce).
const { sub, tenant, scope } = lerClaims(tokens.access_token);
await conexoes.salvar({ sub, tenant, refreshToken: tokens.refresh_token, scope });
return NextResponse.redirect(new URL("/painel", req.url), 302);
} catch (e) {
// Nunca logue token, code nem URL com query; só o código do erro.
const codigo = e instanceof ErroOAuth ? e.codigo : "server_error";
console.warn("entrar-com-asender: falha", { codigo });
// login_required / consent_required (prompt=none): mostre o botão normal.
return NextResponse.redirect(new URL(`/entrar?erro=${encodeURIComponent(codigo)}`, req.url), 302);
}
}