Pular para o conteúdo
Nelcota
EN : Read in English

O esquema auth

12 min de leitura Editar no GitHub

Um contrato público e estável. Toda tabela é SQL comum: você pode ler, exportar e migrar sem Nelcota (veja Sair do Nelcota sem dependência da plataforma).

As tabelas não são expostas pela API REST. O servidor as acessa com o papel interno nelcota_auth, que nenhum JWT pode assumir. anon, authenticated e service_role não têm GRANT nelas (há um teste para isso).

Pré-requisitos

Você precisa de um servidor inicializado em execução. Login com senha está disponível por padrão; SMTP/páginas de links e provedores OAuth são grupos opcionais de configuração. Exemplos de schema/endpoints descrevem a fonte atual; diferenças de versão ficam no changelog do servidor. Nunca exponha hashes de senha do banco ou tokens de links.

auth.users

Coluna Tipo Observações
id uuid PK gen_random_uuid(); é o sub do JWT e auth.uid()
email text único sempre em minúsculas, ≤ 254 caracteres
encrypted_password text string PHC argon2id (veja abaixo); NULL = sem senha
email_confirmed_at timestamptz definido quando a pessoa abre um link de confirmação de cadastro, mágico ou de recuperação (comprova que recebe o e-mail)
raw_user_meta_data jsonb campo data do cadastro; retornado como user_metadata
created_at, updated_at timestamptz
last_sign_in_at timestamptz atualizado a cada login

Formato da senha

E-mails de conta usam um endereço simples de caixa postal: parte local ASCII dot-atom (até 64 bytes), domínio DNS com rótulos não vazios e no máximo 254 bytes no total. Espaços nas extremidades são removidos e o endereço é convertido para minúsculas. Controles, nomes de exibição, partes locais entre aspas e pontos consecutivos são rejeitados antes de calcular o hash ou consultar o banco, com a mesma regra para autenticação pública e contas do painel. Domínios internacionais podem usar sua forma ASCII/Punycode. Endereços malformados existentes não são renomeados automaticamente; um administrador deve verificar a propriedade antes de corrigir essa linha em SQL.

O servidor compartilha um único orçamento limitado de workers Argon2 entre autenticação pública, login do painel e administração de contas. Limites de login do painel se aplicam por IP do cliente; cabeçalhos encaminhados só são usados quando trust_proxy está configurado.

text
$argon2id$v=19$m=19456,t=2,p=1$<salt base64>$<hash base64>

É o formato PHC padrão. Os parâmetros (19 MiB, 2 iterações, paralelismo 1) seguem a recomendação OWASP. Funciona diretamente com qualquer biblioteca argon2: Python argon2-cffi (PasswordHasher().verify(hash, password)), Node argon2 (argon2.verify(hash, password)), Go alexedwards/argon2id, PHP password_verify(), Keycloak/Authentik por importação.

auth.sessions

Uma linha por login.

Coluna Tipo Observações
id uuid PK entra no JWT como a claim session_id
user_id uuid → auth.users ON DELETE CASCADE
created_at, refreshed_at timestamptz
revoked_at timestamptz logout ou reutilização detectada de token de renovação
user_agent, ip text, inet informativo

auth.refresh_tokens

Coluna Tipo Observações
id bigint PK
session_id uuid → auth.sessions a "família" do token
token_hash bytea único SHA-256 do token opaco; o próprio token nunca é armazenado
revoked boolean true após o uso (rotação)
created_at, expires_at timestamptz validade padrão: 30 dias

Rotação e detecção de reutilização

  1. POST /auth/v1/token?grant_type=refresh_token com token R1.
  2. R1 válido → marcado revoked = true; R2 é emitido na mesma sessão.
  3. Se R1 for usado novamente (alguém tem uma cópia), toda a sessão é revogada: R2, R3... param de funcionar. As outras sessões do usuário continuam.

Janela de tolerância para novas tentativas. Um cliente que perdeu a resposta da etapa 2 (queda de rede, duas abas renovando ao mesmo tempo) ainda tem apenas R1. Por 10 segundos após a rotação, apresentar R1 novamente é tratado como nova tentativa: o servidor revoga R2 (e qualquer outro token ativo da sessão emitido após R1) e responde com um novo par para a mesma sessão. Novas tentativas nunca ampliam a janela, contada desde a rotação original (seu refreshed_at). Quando o sucessor de R2 também é rotacionado, ou após a janela, R1 volta a ser reutilização e a etapa 3 se aplica. O cliente ainda deve serializar renovações; a janela apenas absorve novas tentativas.

Limpeza

Uma vez por hora, o servidor exclui tokens de renovação após expires_at (um token rotacionado permanece até lá para ainda detectar reutilização), sessões sem nenhum token e sessões revogadas há mais de 7 dias (com seus tokens). Nada que ainda possa autenticar alguém é removido.

auth.one_time_tokens

Links enviados por e-mail: recuperação de senha, confirmação de cadastro e link mágico.

Coluna Tipo Observações
id bigint PK
user_id uuid → auth.users ON DELETE CASCADE
kind text recovery, signup ou magiclink; um token só funciona como seu tipo
token_hash bytea único SHA-256 do token; o próprio token nunca é armazenado
created_at, expires_at timestamptz validade: recuperação 1 hora, cadastro 24 horas, link mágico 15 minutos
used_at timestamptz definido no uso; o link não funciona novamente

auth.identities

Contas em provedores de login vinculadas a um usuário (veja Login com Google ou GitHub).

Coluna Tipo Observações
id uuid PK
user_id uuid → auth.users ON DELETE CASCADE
provider text google ou github
provider_id text ID da conta no provedor (sub); único por provedor
email text e-mail informado pelo provedor no último login
identity_data jsonb perfil do provedor como recebido
created_at, updated_at, last_sign_in_at timestamptz

auth.flow_states

Logins por provedor em andamento; cada linha dura minutos.

Coluna Tipo Observações
state_hash bytea único SHA-256 do state enviado ao provedor
provider text
provider_verifier text verificador PKCE para o provedor; limpo atomicamente ao assumir o callback, antes da troca HTTP
code_challenge text desafio S256 do aplicativo
redirect_to text página do aplicativo à qual retornar
auth_code_hash bytea único SHA-256 do código entregue ao aplicativo
user_id uuid → auth.users definido quando o provedor responde
created_at, expires_at timestamptz 10 minutos no provedor, depois 5 para resgatar o código

Apenas um callback pode assumir um state, mesmo enquanto o provedor ainda responde. Trocas com falha ou canceladas exigem um novo /authorize; nenhuma transação do banco permanece aberta durante requisições HTTP ao provedor. Requisições de callback têm um orçamento por IP separado de /authorize.

Limitadores em memória acompanham no máximo 50.000 chaves. Ao atingir a capacidade, rejeitam novas chaves com 429 e indicação de nova tentativa em um segundo, enquanto chaves existentes mantêm suas cotas independentes. A limpeza executa no máximo uma vez por segundo sob pressão de capacidade e remove apenas chaves ociosas desde a última requisição aceita por um minuto inteiro, suficiente para recuperar toda a capacidade de rajada do GCRA.

Endpoints

Método e rota Corpo Resposta
POST /auth/v1/signup {email, password, data?} 201 + sessão (201 {user} com confirmação de e-mail ativada)
POST /auth/v1/token?grant_type=password {email, password} 200 + sessão
POST /auth/v1/token?grant_type=refresh_token {refresh_token} 200 + sessão
POST /auth/v1/token?grant_type=pkce {auth_code, code_verifier} 200 + sessão
GET /auth/v1/authorize ?provider&redirect_to&code_challenge&code_challenge_method=S256 303 para o provedor
GET /auth/v1/callback (redirecionamento do provedor) 303 para redirect_to?code=... ou ?error=...
POST /auth/v1/logout Bearer 204 (revoga a sessão)
GET /auth/v1/user Bearer dados do usuário
PUT /auth/v1/user Bearer + {password?, current_password?, data?} 200 + dados do usuário (veja Atualizar o usuário autenticado)
GET /auth/v1/.well-known/jwks.json - JWKS público
POST /auth/v1/recover {email} 200 {} (independentemente de a conta existir)
POST /auth/v1/magiclink {email} 200 {} (independentemente de a conta existir)
POST /auth/v1/resend {type: "signup", email} 200 {} (independentemente de a conta existir)
POST /auth/v1/verify {type: "recovery", token, password} ou {type: "signup" | "magiclink", token} 200 + sessão

Sessão:

JSON
{
  "access_token": "<JWT>",
  "token_type": "bearer",
  "expires_in": 900,
  "expires_at": 1767225600,
  "refresh_token": "<opaque>",
  "user": { "id": "...", "email": "...", "user_metadata": {}, "created_at": "...", "last_sign_in_at": "...", "email_confirmed_at": null }
}

Erros: 422 invalid_email ou 422 weak_password (e-mail ou senha fora das regras), 422 validation_failed (um valor exigido pelo endpoint está ausente, como refresh_token), 400/422 invalid_body (corpo não é JSON ou um campo tem tipo incorreto), 409 user_already_exists, 400 invalid_grant (credenciais ou renovação inválidas, com a mesma mensagem para e-mail desconhecido e senha incorreta), 429 rate_limited com Retry-After, 403 signup_disabled, 400 email_not_confirmed (senha correta, confirmação de e-mail pendente).

Links: 403 recovery_disabled, confirmation_disabled ou magiclink_disabled (o projeto não configurou esse fluxo), 400 invalid_grant (link inválido, expirado, já usado ou de outro tipo), 400 unsupported_type.

Os fluxos de e-mail compartilham 16 vagas de envio por processo e um prazo total de 30 segundos, incluindo implementações personalizadas. A sobrecarga retorna 503 mail_busy antes de criar uma conta ou emitir um token, com a mesma resposta para contas existentes e desconhecidas. Cada envio em segundo plano mantém sua vaga até terminar ou atingir o prazo. Erros e timeouts de SMTP são registrados; o envio continua sendo uma operação em segundo plano de melhor esforço, sem fila persistente de entrega.

Senha: 8 a 256 caracteres. Limite de requisições: NELCOTA_AUTH_RATE_LIMIT_PER_MINUTE por IP (padrão 30) e o mesmo limite por e-mail no login por senha. Renovações e resgates PKCE (grant_type=refresh_token / pkce) usam um orçamento separado por IP de 10 vezes esse valor (300 por padrão), então abas abertas renovando não consomem o orçamento de login por senha de todos atrás do mesmo IP.

Atualizar o usuário autenticado

PUT /auth/v1/user com o token de acesso do usuário altera sua própria conta. Envie pelo menos um de:

  • data: mesclado em user_metadata (raw_user_meta_data) em um nível: as chaves enviadas substituem as armazenadas, as demais permanecem e uma chave enviada como null é removida. {"data": {"plan": "pro", "tmp": null}}.
  • password (mesmas regras do cadastro): exige current_password quando a conta já tem senha (422 validation_failed sem ela, 400 invalid_grant quando incorreta). Uma conta sem senha (link mágico ou login por provedor) pode defini-la diretamente. As outras sessões do usuário são revogadas; a sessão chamadora e seu token de renovação continuam funcionando. Tentativas contam para o limite por minuto, por usuário.

A sessão do token de acesso ainda deve estar ativa (sem logout ou revogação): caso contrário, 401 invalid_token. A resposta é o usuário atualizado, como em GET /auth/v1/user. Alterar o e-mail ainda não é suportado (exige confirmação do novo endereço); um administrador pode alterá-lo em SQL.

Recuperação de senha

Desativada até o projeto configurar SMTP (Nelcota não tem servidor de e-mail próprio; use o do seu provedor: Postmark, SES, Resend...). No .env do projeto, as três configurações juntas:

shell
NELCOTA_SMTP_URL=smtps://user:[email protected]:465   # or smtp://...:587?tls=required
NELCOTA_SMTP_FROM='Shop <[email protected]>'
NELCOTA_PASSWORD_RECOVERY_URL=https://app.shop.com/new-password

Uma configuração incompleta impede o servidor de iniciar (melhor que descobrir no primeiro "esqueci minha senha").

  1. O aplicativo chama POST /auth/v1/recover {email}. A resposta é sempre 200 {}, então não revela quem tem conta. Se a conta existir, chega um e-mail (em inglês, assunto "Reset your password") com o link https://app.shop.com/new-password#type=recovery&token=....
  2. Essa página do aplicativo lê o token do fragmento (location.hash), pede a nova senha e chama POST /auth/v1/verify {type: "recovery", token, password}.
  3. A resposta é uma sessão (mesmo formato do login): a pessoa está autenticada.

Garantias:

  • O token viaja no fragmento da URL, que o navegador não envia a nenhum servidor: não aparece nos logs nem no Referer.
  • É válido por 1 hora e uma única vez. Pedir novamente invalida o link anterior.
  • No máximo um e-mail por minuto para a mesma conta, além do limite por IP: o endpoint não pode ser usado para inundar a caixa de entrada de alguém.
  • O e-mail é enviado em segundo plano: o tempo de resposta não revela se a conta existe, e uma falha SMTP vai para o log em vez de se tornar um erro.
  • Alterar a senha encerra todas as sessões da conta (tokens de renovação param de funcionar imediatamente; JWTs de acesso já emitidos permanecem válidos até expirar).

Confirmação de cadastro

Desativada por padrão. Com SMTP configurado, definir a página que recebe o link a ativa:

shell
NELCOTA_EMAIL_CONFIRMATION_URL=https://app.shop.com/welcome
  1. POST /auth/v1/signup cria a conta e responde 201 {"user": {...}} sem sessão; um e-mail ("Confirm your email") contém https://app.shop.com/welcome#type=signup&token=..., válido por 24 horas.
  2. A página lê o token do fragmento e chama POST /auth/v1/verify {type: "signup", token}: o e-mail é confirmado e a resposta é uma sessão.
  3. Até lá, o login por senha responde 400 email_not_confirmed, apenas após uma senha correta. POST /auth/v1/resend {type: "signup", email} envia um novo link para uma conta não confirmada (mesmo intervalo e 200 {} da recuperação).

Abrir um link mágico ou de recuperação também confirma o e-mail. Contas já não confirmadas quando o fluxo foi ativado também são bloqueadas; marque-as com "Confirmar e-mail" na página Usuários do painel (ou UPDATE auth.users SET email_confirmed_at = now()) se devem continuar entrando. Contas criadas no painel são confirmadas desde o início.

Quando a recuperação confirma uma conta pela primeira vez, remove identidades de provedores e códigos OAuth/e-mail pendentes estabelecidos antes dessa prova de acesso à caixa de entrada, revoga sessões anteriores e instala a nova senha do proprietário na mesma transação. A recuperação de uma conta já confirmada preserva seus provedores vinculados, ainda alterando a senha e revogando as sessões existentes.

Login sem senha para contas existentes, ativado quando sua página é configurada:

shell
NELCOTA_MAGIC_LINK_URL=https://app.shop.com/signed-in
  1. POST /auth/v1/magiclink {email} sempre responde 200 {}. Se a conta existir, um e-mail ("Your sign-in link") contém https://app.shop.com/signed-in#type=magiclink&token=..., válido por 15 minutos.
  2. A página chama POST /auth/v1/verify {type: "magiclink", token} e recebe uma sessão; o e-mail é considerado confirmado.

Na primeira prova de propriedade da caixa de entrada, um link mágico limpa qualquer senha do cadastro não confirmado e revoga sessões anteriores, identidades de provedores, códigos OAuth pendentes e links de e-mail. Outra pessoa pode ter cadastrado esse endereço. O proprietário da caixa de entrada pode definir uma nova senha pela recuperação. Contas já confirmadas mantêm sua senha e sessões; a confirmação de cadastro ainda aprova a senha do cadastro. Sessões revogadas não podem renovar, enquanto JWTs de acesso emitidos anteriormente mantêm sua expiração normal (como no logout e na recuperação de senha).

Não cria contas: uma solicitação de e-mail permitiria que qualquer pessoa reservasse endereços antes de seus proprietários se cadastrarem. Mesmas garantias da recuperação: token apenas no fragmento, uso único, um e-mail por minuto por conta e envio em segundo plano.

Login com Google ou GitHub

Desativado até um provedor ser configurado. Cadastre um aplicativo OAuth no provedor com o callback https://<api domain>/auth/v1/callback, depois:

shell
NELCOTA_API_URL=https://api.shop.com
NELCOTA_OAUTH_REDIRECT_URLS=https://shop.com/auth,http://localhost:5173/auth
NELCOTA_OAUTH_GOOGLE_CLIENT_ID=...
NELCOTA_OAUTH_GOOGLE_CLIENT_SECRET=...
NELCOTA_OAUTH_GITHUB_CLIENT_ID=...
NELCOTA_OAUTH_GITHUB_CLIENT_SECRET=...

Um provedor parcialmente configurado, ou sem as duas URLs, impede o servidor de iniciar.

  1. O aplicativo cria um par PKCE: um code_verifier aleatório (43 a 128 caracteres) mantido no navegador e code_challenge = base64url(SHA-256(code_verifier)). Envia a pessoa para GET /auth/v1/authorize?provider=github&redirect_to=https://shop.com/auth&code_challenge=...&code_challenge_method=S256.
  2. Após o provedor, a pessoa chega a https://shop.com/auth?code=... (ou ?error=...).
  3. A página chama POST /auth/v1/token?grant_type=pkce {auth_code, code_verifier} e recebe uma sessão (mesmo formato do login).

Erros em redirect_to: access_denied (a pessoa recusou), provider_error, email_conflict (o provedor não verificou um e-mail que pertence a uma conta), email_required (o provedor não forneceu e-mail), signup_disabled. /authorize responde 400 redirect_not_allowed, 400 invalid_code_challenge ou 403 provider_disabled; /callback responde 400 invalid_state para um login desconhecido, expirado ou já respondido.

Qual conta abre:

  • uma conta de provedor já vinculada: seu usuário, mesmo se o e-mail mudou no provedor;
  • um e-mail verificado pelo provedor que corresponde a uma conta confirmada: essa conta, que recebe a identidade (sua senha continua funcionando);
  • um e-mail verificado que corresponde a uma conta não confirmada: o proprietário verificado assume a conta; senha anterior, sessões, identidades de provedores e códigos pendentes de e-mail/OAuth são removidos, depois a identidade verificada é vinculada, pois quem criou a conta pode não ser dono do endereço;
  • um e-mail não verificado que corresponde a uma conta: recusado (email_conflict);
  • nenhuma conta: uma nova, sem senha, confirmada quando o provedor verificou o e-mail, com user_metadata name e avatar_url do provedor.

Garantias: redirect_to deve estar sob uma das NELCOTA_OAUTH_REDIRECT_URLS (mesma origem, caminho abaixo), então o endpoint não pode redirecionar para outro lugar. O código na URL do aplicativo funciona uma vez, por 5 minutos, e apenas com o verificador guardado pelo aplicativo. Nelcota usa seu próprio PKCE e state com o provedor e se comunica com ele apenas de servidor para servidor.

Veja JWT/papéis, API REST e solução de problemas.