SuperdindaDocs

Autenticação

Login sem senha por código ou magic link, Google e Apple, JWT de acesso curto, refresh em cookie httpOnly e sessões revogáveis no Postgres.

Não existe senha. A identidade é o e-mail (AD-005). Toda entrada termina do mesmo jeito: um access JWT no corpo da resposta e um refresh token num cookie httpOnly.

Entrar por e-mail

  1. POST /auth/otp/request com email e delivery:

    • "code": seis dígitos pra digitar (pensado pro celular);
    • "link": um magic link pra clicar (pensado pro desktop).

    Campos opcionais: displayName (só usado se o e-mail ainda não tem conta) e audience (platform, o padrão, ou backoffice). Responde 202, exista a conta ou não. E-mail desconhecido vira conta nova. As únicas recusas são um endereço que não recebe e-mail (422 EMAIL_DOMAIN_INVALID) e pedidos demais (429 RATE_LIMITED).

  2. POST /auth/otp/verify com { email, code } ou { token } (o ?token= do link). Responde 200 com { accessToken, expiresAtMs } e grava o cookie de refresh.

Regras do desafio (login_challenges):

RegraValor
Validade10 minutos (CHALLENGE_TTL_MS)
Tentativas erradas de códigoOTP_MAX_ATTEMPTS (padrão 5), contadas por endereço no Redis (otp:wrong:<sha>), tenha o e-mail conta ou não
O que fica no bancoSó o hash SHA-256 (code:<userId>:<código> ou link:<token>)
Pedido novoEncerra o prazo dos desafios abertos (um link antigo passa a ler como vencido) e zera as tentativas erradas
UsoÚnico: o consume acontece num só UPDATE

O magic link aponta pra MAGIC_LINK_URL?token=…, uma página do app que faz o POST em /auth/otp/verify. O backend nunca recebe o token por GET, então o prefetch de clientes de e-mail não queima o link (AD-005).

Por que o verify recusou

O verify responde 401 com um código que diz o motivo, sem revelar se o e-mail tem conta:

codeQuando
OTP_INVALIDCódigo errado (details.attemptsLeft). Um e-mail sem conta recebe a mesma resposta e gasta tentativas igual
OTP_LOCKEDAcabaram as tentativas. Vale até pro código certo. Um pedido novo desbloqueia
OTP_EXPIRED / OTP_USEDCódigo certo, mas vencido ou trocado / já usado
OTP_LINK_INVALID / OTP_LINK_EXPIRED / OTP_LINK_USEDO mesmo pro magic link

EXPIRED e USED só aparecem pra quem mandou o segredo de verdade, ou seja, pra quem tem acesso ao e-mail.

POST /auth/otp/resend com { linkToken } (o ?token= do link vencido ou usado). Quem decide pra onde vai o link novo é o token, nunca um endereço digitado. Sempre 202 com { emailMasked } (por exemplo an•••@example.com). emailMasked: null quer dizer que o token não é nosso ou tem mais de OTP_RESEND_MAX_AGE_HOURS (padrão 168), e então nada é enviado. O resend anula código ou link ainda vivo e zera as tentativas erradas.

Login, verify e resend têm limite de taxa (429 RATE_LIMITED). Veja Limites de taxa.

Entrar com Google ou Apple

  • POST /auth/providers/{provider}/login, com provider igual a google ou apple e corpo { idToken, displayName? }. O ID token é conferido contra as chaves do provedor. Um e-mail verificado que já tem conta é vinculado a ela, e um desconhecido cria conta. A Apple não manda o nome no token, então o app envia displayName no primeiro login.
  • POST /auth/providers/{provider}/link (com Bearer) vincula o provedor a quem já está logado.
  • Respostas: 401 se o token não confere ou o e-mail não é verificado, 503 se o provedor não está configurado (GOOGLE_CLIENT_IDS / APPLE_CLIENT_IDS vazios).

Em aberto

O login por provedor sempre abre uma sessão do cliente platform: o loginWithProvider não recebe o header do cliente. Pelo backoffice, só o login por código funciona hoje. Não há decisão registrada sobre isso.

Tokens e sessões

PeçaComo é
Access JWTHS256, assinado com JWT_SECRET. Claims sub (uid do usuário), sid (hash da sessão), iat, exp. Vale JWT_ACCESS_TTL_SECONDS (padrão 900). Vai no header Authorization: Bearer <access-jwt>
Refresh tokenOpaco, só no cookie httpOnly, SameSite=Strict, Path=/, Secure conforme SESSION_COOKIE_SECURE. O banco guarda só o SHA-256 (chave de sessions)
Vida da sessão30 dias no platform. No backoffice, ADMIN_SESSION_TTL_HOURS (padrão 12)
RevogaçãoToda rota protegida confere se o sid ainda vive e se o usuário não foi desativado. Revogar ou desativar vale na hora, não no exp

O Path=/ é de propósito: o app pode falar com a API por um prefixo de proxy, e um cookie preso em /auth nunca seria enviado (backend/src/http/cookies.ts).

Renovar e sair

  • POST /auth/refresh: troca o cookie por um access JWT novo (200, mesmo formato do verify). O refresh token não é rotacionado, só ganha last_seen_at. Se o cookie está morto, responde 401 e apaga o cookie.
  • POST /auth/logout: revoga a sessão e apaga o cookie. Sempre 204.
  • GET /auth/session: confere o Bearer e devolve o usuário, os provedores vinculados (methods.providers) e deletionScheduledFor.
  • DELETE /admin/users/{uid}/sessions: um operador com escrita encerra todas as sessões de alguém.

O backoffice

O app e o backoffice falam com a mesma API, no mesmo host. Por isso o cliente vem num header próprio:

platform (padrão)backoffice
Headerausente, ou x-superdinda-client: platformx-superdinda-client: backoffice
Cookie de refreshsuperdinda_refreshsuperdinda_admin_refresh
Pedido de códigoaudience: "platform"audience: "backoffice": nunca cria conta e só envia pra quem pode entrar no painel. A resposta é a mesma nos dois casos
Vida da sessão30 diasADMIN_SESSION_TTL_HOURS

Quem entra no painel é decidido por platform_members (AD-018), não por users.role. Papéis: owner, admin e viewer. PLATFORM_ADMINS só cria o primeiro owner. Se a pessoa perde o vínculo, a sessão de backoffice morre no próximo refresh.

Proteger uma rota

MiddlewareO que exige
requireAuthBearer válido, sessão viva, usuário não desativado. Põe user e auth (sid, client) no contexto. Toda recusa é o mesmo 401
requireAccount(role)Ser membro vivo da conta {uid} (guardian ou owner). Fora dela: 404
requireSeat(role)Ser membro da conta dona da vaga {uid}. Fora dela: 404
requireOperatorSessão backoffice + vínculo em platform_members. Senão: 403
requireOperatorWrite / requireOwnerPapel owner ou admin / só owner
requireAdminusers.role === "admin". Ainda existe em middleware/auth.ts, mas nenhuma rota usa: as rotas /admin/* passaram pra requireOperator (AD-018). O backend/README.md ainda cita o nome antigo
fetch com refresh automático
async function api(path: string, init: RequestInit = {}, token?: string) {
  const call = (t?: string) =>
    fetch(`https://api.superdinda.com${path}`, {
      ...init,
      credentials: "include", // manda o cookie de refresh
      headers: { ...init.headers, ...(t && { authorization: `Bearer ${t}` }) },
    });
  let res = await call(token);
  if (res.status === 401 && !path.startsWith("/auth/")) {
    const refreshed = await fetch("https://api.superdinda.com/auth/refresh", {
      method: "POST",
      credentials: "include",
    }).then((r) => r.json());
    if (refreshed.success) res = await call(refreshed.data.accessToken);
  }
  return res.json(); // { success, data } ou { success, error }
}

Fontes

  • backend/src/http/routes/auth/*.route.ts, backend/src/http/routes/auth/schemas.ts
  • backend/src/domains/auth/crypto/jwt.ts, crypto/otp.ts, entities/challenge.entity.ts, entities/session.entity.ts
  • backend/src/domains/auth/use-cases/request-otp.ts, verify-otp.ts, open-session.ts, refresh-session.ts, authenticate.ts, login-with-provider.ts
  • backend/src/http/cookies.ts, backend/src/core/clients.ts, backend/src/http/app.ts
  • backend/src/http/middleware/auth.ts, account.ts, seat.ts, operator.ts
  • backend/src/domains/auth/use-cases/code-lock.ts, resend-link.ts, backend/src/domains/auth/errors.ts
  • backend/src/domains/operators/use-cases/membership.ts, backend/src/config/env.ts, backend/.env.example
  • Notas da frente k-otp (k-otp-api.md)
  • AD-005, AD-016, AD-018

On this page