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
-
POST /auth/otp/requestcomemailedelivery:"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) eaudience(platform, o padrão, oubackoffice). 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). -
POST /auth/otp/verifycom{ email, code }ou{ token }(o?token=do link). Responde200com{ accessToken, expiresAtMs }e grava o cookie de refresh.
Regras do desafio (login_challenges):
| Regra | Valor |
|---|---|
| Validade | 10 minutos (CHALLENGE_TTL_MS) |
| Tentativas erradas de código | OTP_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 banco | Só o hash SHA-256 (code:<userId>:<código> ou link:<token>) |
| Pedido novo | Encerra 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:
code | Quando |
|---|---|
OTP_INVALID | Código errado (details.attemptsLeft). Um e-mail sem conta recebe a mesma resposta e gasta tentativas igual |
OTP_LOCKED | Acabaram as tentativas. Vale até pro código certo. Um pedido novo desbloqueia |
OTP_EXPIRED / OTP_USED | Código certo, mas vencido ou trocado / já usado |
OTP_LINK_INVALID / OTP_LINK_EXPIRED / OTP_LINK_USED | O 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.
Pedir um link novo a partir de um morto
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, comproviderigual agoogleouapplee 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 enviadisplayNameno primeiro login.POST /auth/providers/{provider}/link(com Bearer) vincula o provedor a quem já está logado.- Respostas:
401se o token não confere ou o e-mail não é verificado,503se o provedor não está configurado (GOOGLE_CLIENT_IDS/APPLE_CLIENT_IDSvazios).
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ça | Como é |
|---|---|
| Access JWT | HS256, 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 token | Opaco, 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ão | 30 dias no platform. No backoffice, ADMIN_SESSION_TTL_HOURS (padrão 12) |
| Revogação | Toda 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ó ganhalast_seen_at. Se o cookie está morto, responde401e apaga o cookie.POST /auth/logout: revoga a sessão e apaga o cookie. Sempre204.GET /auth/session: confere o Bearer e devolve o usuário, os provedores vinculados (methods.providers) edeletionScheduledFor.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 | |
|---|---|---|
| Header | ausente, ou x-superdinda-client: platform | x-superdinda-client: backoffice |
| Cookie de refresh | superdinda_refresh | superdinda_admin_refresh |
| Pedido de código | audience: "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ão | 30 dias | ADMIN_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
| Middleware | O que exige |
|---|---|
requireAuth | Bearer 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 |
requireOperator | Sessão backoffice + vínculo em platform_members. Senão: 403 |
requireOperatorWrite / requireOwner | Papel owner ou admin / só owner |
requireAdmin | users.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 |
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.tsbackend/src/domains/auth/crypto/jwt.ts,crypto/otp.ts,entities/challenge.entity.ts,entities/session.entity.tsbackend/src/domains/auth/use-cases/request-otp.ts,verify-otp.ts,open-session.ts,refresh-session.ts,authenticate.ts,login-with-provider.tsbackend/src/http/cookies.ts,backend/src/core/clients.ts,backend/src/http/app.tsbackend/src/http/middleware/auth.ts,account.ts,seat.ts,operator.tsbackend/src/domains/auth/use-cases/code-lock.ts,resend-link.ts,backend/src/domains/auth/errors.tsbackend/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
