O esquema auth
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.
$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
POST /auth/v1/token?grant_type=refresh_tokencom token R1.- R1 válido → marcado
revoked = true; R2 é emitido na mesma sessão. - 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:
{
"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 emuser_metadata(raw_user_meta_data) em um nível: as chaves enviadas substituem as armazenadas, as demais permanecem e uma chave enviada comonullé removida.{"data": {"plan": "pro", "tmp": null}}.password(mesmas regras do cadastro): exigecurrent_passwordquando a conta já tem senha (422 validation_failedsem ela,400 invalid_grantquando 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:
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-passwordUma configuração incompleta impede o servidor de iniciar (melhor que descobrir no primeiro "esqueci minha senha").
- O aplicativo chama
POST /auth/v1/recover {email}. A resposta é sempre200 {}, 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 linkhttps://app.shop.com/new-password#type=recovery&token=.... - Essa página do aplicativo lê o token do fragmento (
location.hash), pede a nova senha e chamaPOST /auth/v1/verify {type: "recovery", token, password}. - 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:
NELCOTA_EMAIL_CONFIRMATION_URL=https://app.shop.com/welcomePOST /auth/v1/signupcria a conta e responde201 {"user": {...}}sem sessão; um e-mail ("Confirm your email") contémhttps://app.shop.com/welcome#type=signup&token=..., válido por 24 horas.- 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. - 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 e200 {}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.
Link mágico
Login sem senha para contas existentes, ativado quando sua página é configurada:
NELCOTA_MAGIC_LINK_URL=https://app.shop.com/signed-inPOST /auth/v1/magiclink {email}sempre responde200 {}. Se a conta existir, um e-mail ("Your sign-in link") contémhttps://app.shop.com/signed-in#type=magiclink&token=..., válido por 15 minutos.- 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:
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.
- O aplicativo cria um par PKCE: um
code_verifieraleatório (43 a 128 caracteres) mantido no navegador ecode_challenge = base64url(SHA-256(code_verifier)). Envia a pessoa paraGET /auth/v1/authorize?provider=github&redirect_to=https://shop.com/auth&code_challenge=...&code_challenge_method=S256. - Após o provedor, a pessoa chega a
https://shop.com/auth?code=...(ou?error=...). - 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_metadatanameeavatar_urldo 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.
Guias relacionados
Veja JWT/papéis, API REST e solução de problemas.