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.

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:

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âmetroobrigatórionota
response_typesimsempre code
client_idsimo seu
redirect_urisimigual, caractere a caractere, a uma cadastrada
scopesimseparado por espaço; openid é sempre incluído; veja o catálogo na seção 4
statesimanti-CSRF; volta igual no redirect
noncerecomendadovolta no ID token; confira
code_challenge / code_challenge_methodsimsomente S256
promptnãonone, login ou consent (veja abaixo)

O que o usuário vê:

  1. Sem sessão Asender: vai ao login (com 2º fator, passkey etc.) e volta.
  2. 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.
  3. Já autorizou antes e a sessão está viva: redirect imediato (o "1 clique").

prompt:

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:

  1. state igual ao guardado (e descarte-o: uso único);
  2. iss igual a https://auth.asender.net. O servidor publica authorization_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 o code_verifier) para o lugar errado. Se iss faltar 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:

claimsignificado
isshttps://auth.asender.net
subid público do usuário (imutável; nunca use e-mail como id)
audhttps://api.asender.net (o recurso, não o seu client)
azpo seu client_id
tenanta conta escolhida pelo usuário (acc_...)
scopeescopos efetivos, separados por espaço
exttrue: token de terceiro
jti, iat, expid, 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:

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.

escopodescrição
openidConfirmar quem é o usuário (necessário para entrar).
profileVer o nome do perfil.
emailVer o e-mail principal.
offline_accessContinuar conectado em segundo plano (emite refresh token).
account:readNome e dados cadastrais da conta escolhida.
leads:readContatos do CRM e envios de formulário da conta.
conversions:readEventos de conversão e entregas para Meta, Google e TikTok.
forms:readFormulários da conta e suas configurações.
analytics:readMétricas de páginas e formulários.
campaigns:readCampanhas de e-mail marketing e relatórios.
email:readMensagens e eventos de e-mail da conta.
messaging:readMensagens e relatórios de SMS, WhatsApp e push.
conversations:readConversas de atendimento.
journeys:readJornadas de automação.
logs:readRegistros de chamadas e eventos da conta.
webhooks:manageCriar e remover webhooks da conta (planejado).
leads:writeCriar 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:

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):

errorquandoo que fazer
invalid_scopeescopo desconhecido ou não liberado ao appcorrija o pedido; peça liberação ao time Asender
access_deniedo usuário negourespeite; não insista em loop
login_requiredprompt=none sem sessãomostre o botão e reinicie sem prompt=none
consent_requiredprompt=none sem consentimento que cubra os escoposidem
invalid_requestparâmetro faltando ou inválido; prompt=none combinado com outro valorcorrija
temporarily_unavailable / server_errorfalha momentâneatente 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):

HTTPerrorcausa comum
400invalid_grantcode 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
400invalid_request / unsupported_grant_typecorpo malformado, grant não suportado
401invalid_clientclient_id/client_secret errados, ou introspecção por client que não é confidencial
5xxserver_errorfalha; 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)

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

Sincronização incremental (boas práticas)

  1. Carga inicial: percorra a lista seguindo next_cursor até null, com limit=200. Anote o instante de início da carga (T0) antes da primeira página.
  2. Rodadas seguintes: peça updated_after=<marca anterior> e percorra os cursores. Ao terminar com sucesso, grave como nova marca o T0 daquela rodada (não o fim), menos uma pequena folga (por exemplo, 60 s) para não perder escritas simultâneas.
  3. Idempotência: faça upsert pelo id do recurso; a folga e os retries geram repetição, e isso é esperado.
  4. Não recomece do zero por erro transitório: retome do último cursor gravado.
  5. Espace as rodadas (minutos, não segundos) e não faça varredura completa recorrente.
  6. Rate limit: o limite é por app e conta. Em 429, espere o tempo do header Retry-After (segundos) antes de tentar de novo; se não vier, use backoff exponencial com jitter. Não paralelize além do necessário. Em 5xx, tente de novo com espera crescente.
  7. 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.

escoporotaproduto exigido do usuárioincrementalcampos principais
account:readGET /v1/account(só a conta)nãodados da conta, ClientId
leads:readGET /v1/leads, GET /v1/leads/{id}CRMupdated_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:readGET /v1/formsPáginasnão (lista pequena)id, name, external_id, requires_consent, legal_basis, fields (name, label, type, required, options, key)
forms:readGET /v1/forms/{id}/submissionsPáginascreated_after (created_at)id, form_id, mode, fields (em claro), source (utm_*, referrer), consent (state, legal_basis, at), created_at
conversions:readGET /v1/conversionsCRMcreated_after (created_at)id, lead_id, type (sent, ignored, result), event, value, currency, platform, delivery_status, reason, source, occurred_at, created_at
campaigns:readGET /v1/campaignsE-mail Marketingupdated_afterid, name, status, subject, recipients, sent, failed, scheduled_at, updated_at
campaigns:readGET /v1/campaigns/{id}E-mail Marketingn/ao mesmo objeto da campanha, sem envelope
campaigns:readGET /v1/campaigns/{id}/reportE-mail Marketingn/adata.totals (enviados, entregues, aberturas, cliques, bounces)
campaigns:readGET /v1/reports/email/overview, /timeseries, /top-emails, /domainsE-mail Marketingjanela days ou from+to/perioddata com totals, previous, points
email:readGET /v1/emails, GET /v1/emails/{id}Marketingcreated_afterformato legado (PascalCase)
messaging:readGET /v1/messaging/sms/messages, /whatsapp/messages, /push/messagesSMS, WhatsApp ou Pushcreated_after (ordem crescente)id, to, status, queued_at, created_at (+ segments no SMS, title no push)
messaging:readGET /v1/messaging/{sms,whatsapp,push}/reports/overviewidemjanela daysdata com totais por status
analytics:readGET /v1/analytics/overview, /timeseries, /breakdown, /formsPáginasjanela from/to (até 92 dias)totals, data[] (visitas, visualizações, conversões, funil de formulário)
conversations:readGET /v1/conversationsAtendimentoupdated_after (atividade, inclusive mensagem nova)id, channel, status, lead_id, assigned_to, updated_at, message_count
conversations:readGET /v1/conversations/{id}/messagesAtendimentocreated_afterid, direction, author_type, text, delivery_status, sent_at, attachment_count
journeys:readGET /v1/journeys, GET /v1/journeys/{id}CRMupdated_afterid, name, active, trigger, steps, stats, updated_at
webhooks:manage, leads:writegestão e escritaplanejado

Pontos que valem para todas:

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);
  }
}