Ir para o conteúdo

Bundled auth flow (signup / activate / login / reset)

Desde v0.31.0 o SDK fornece o ciclo completo de conta local — signup com email/senha, ativação por link, login com JWT pair, reset de senha — via UserAuthService + make_auth_router. Endpoints prontos pra mount (incluindo POST /auth/refresh desde v0.65.0), templates Jinja2 bundled, settings flags controlando se o link sai por e-mail ou no body da resposta, e quatro modos pré-pensados pra dev / staging / produção / CI.

Vá direto ao seu caso

A tabela abaixo mapeia o que o usuário quer fazer → a seção e os endpoints. Comece pelo seu caso; volte ao setup só quando precisar ligar as peças.

Quero… Está logado? Seção Endpoints
Criar conta + ativar por e-mail Setup signupactivate/{token}
Entrar Endpoints login
Esqueci minha senha ❌ não Recuperação de senha password-reset/requestpassword-reset/confirm
Trocar minha senha ✅ sim Trocar senha logado password-change
Trocar meu e-mail ✅ sim Trocar e-mail email-change/requestemail-change/confirm
Re-verificar meu e-mail atual ✅ sim Re-verificar e-mail email-verify/requestemail-verify/confirm
Perdi acesso ao meu e-mail ❌ não Recuperação de e-mail email-recovery/requestemail-change/confirm
Minha sessão expirou Refresh refresh

Os dois "esqueci": senha perdida → Recuperação de senha; caixa de e-mail perdida → Recuperação de e-mail.

Conteúdo da receita

  1. Setup mínimo — instalação dos extras + wiring de quatro objetos (AsyncDatabaseManager, EmailUtils, UserAuthService, make_auth_router).
  2. UserTokenModel concretoBaseUserTokenModel é abstrato, projeto cria a tabela final.
  3. Endpoints — tabela de todos os endpoints + payload + comportamento.
  4. Recuperação de senha — o fluxo "esqueci a senha", passo a passo, mais trocar a senha logado.
  5. Troca e recuperação de e-mail — trocar e-mail logado, re-verificar, e recuperar quando a caixa se perdeu.
  6. Settings — variáveis de ambiente — env vars em grupos (JWT, política de senha, fluxo de e-mail, TTL, URLs/templates, páginas backend) — cada uma em tabela tipada, não num blob.
  7. Anatomia de um e-mail: como link, template e URL se encaixam — desambigua os três conceitos que mais confundem.
  8. Cinco modos de operação — produção, dev com SMTP local (Mailhog / smtp4dev), dev sem SMTP, CI sem ativação e backend-only (links e páginas servidas direto pelo backend).
  9. Mailhog vs smtp4dev — qual escolher pra dev local — comparativo + receitas docker-compose copy-paste.
  10. Customizando os templates de e-mail — override do activation.html e password_reset.html + variáveis disponíveis no contexto Jinja2.
  11. Segurança — como o token é armazenado, TTL, anti-enumeração.
  12. Próximos passos.

Setup mínimo

Requer:

  • [auth] (bcrypt + PyJWT) — obrigatório, sempre.
  • [email] (aiosmtplib + Jinja2 + email-validator) — opcional; quando ausente, os links vão no body da resposta em vez de e-mail.
uv add "tempest-fastapi-sdk[auth,email]>=0.89.0"
# src/api/app.py

from fastapi import FastAPI

from tempest_fastapi_sdk import (
    AsyncDatabaseManager,
    EmailUtils,
    UserAuthService,
    make_auth_router,
)

from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel

app = FastAPI()


db = AsyncDatabaseManager(settings.DATABASE_URL)

# EmailUtils — só instancie se [email] estiver instalado E você quiser e-mail
# real (modos A e B abaixo). Nos modos C e D, passe email=None pro service.
emails = EmailUtils(
    host=settings.SMTP_HOST,
    port=settings.SMTP_PORT,
    username=settings.SMTP_USERNAME,
    password=settings.SMTP_PASSWORD,
    from_addr=settings.SMTP_FROM_ADDR,
    template_dir="emails",  # diretório onde seus templates custom moram
)

auth_service = UserAuthService(
    db=db,                    # necessário pra current_user_dependency (ver seção final)
    user_model=UserModel,
    token_model=UserTokenModel,
    auth_settings=settings,   # mistura AuthSettings (ver seção 4)
    jwt_settings=settings,    # mistura JWTSettings
    email=emails,             # ou None — controla envio real vs link no body
)

app.include_router(
    make_auth_router(
        auth_service,
        session_factory=db.session_dependency,
    ),
)

TL;DR de quatro objetos

AsyncDatabaseManager → conexão. EmailUtils → SMTP + Jinja2. UserAuthService → regras de negócio (5 métodos). make_auth_router → cola tudo em 5 endpoints HTTP.


UserTokenModel concreto

BaseUserTokenModel é abstrato — projeto cria a tabela concreta porque a FK pra users precisa do nome da sua tabela. Exemplo src/db/models/user_token.py:

from uuid import UUID

from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseUserTokenModel


class UserTokenModel(BaseUserTokenModel):
    """Concrete token table for activation / reset / email-verification."""

    __tablename__ = "user_tokens"

    user_id: Mapped[UUID] = mapped_column(
        ForeignKey("users.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )

E importar em src/db/models/__init__.py pra Alembic ver:

from src.db.models.user import UserModel
from src.db.models.user_token import UserTokenModel

__all__: list[str] = ["UserModel", "UserTokenModel"]

Gerar migration (a primeira vez, bootstrape o Alembic com tempest db init):

# Só na primeira vez — gera alembic/, alembic.ini e env.py:
uv run tempest db init

# A partir daí, ciclo normal de revisão:
uv run tempest db revision -m "users + user_tokens"
uv run tempest db upgrade

Endpoints

Método Path Body / Output Comportamento
POST /auth/signup SignupSchemaSignupResponseSchema Cria user. Emite e-mail (modos A/B) ou devolve link no body (modo C). Se AUTH_AUTO_ACTIVATE=True, user nasce ativo e JWT pair volta direto (modo D).
POST /auth/activate/{token} — → ActivationResponseSchema Consome token + is_active=True + emite JWT pair.
POST /auth/login LoginSchemaLoginResponseSchema Email + senha → JWT pair. Erros genéricos (não enumera contas).
GET /auth/me (v0.198.0+) — → AuthUserSchema Autenticado. Devolve a conta dona do bearer token. Nunca serializa o hash da senha: o handler entrega o modelo inteiro e o response_model filtra. Troque o schema por um seu com me_response_model= pra expor colunas próprias.
POST /auth/password-reset/request PasswordResetRequestSchemaPasswordResetResponseSchema Sempre HTTP 202 + corpo genérico. Link via e-mail (A/B) ou no body (C).
POST /auth/password-reset/confirm PasswordResetConfirmSchemaLoginResponseSchema Consome token + grava nova senha + emite JWT pair.
POST /auth/password-change PasswordChangeSchema204 Autenticado (bearer token). Troca a própria senha: confirma a senha atual + grava a nova. Sem token de e-mail.
POST /auth/email-change/request (v0.92.0+) EmailChangeRequestSchemaEmailChangeResponseSchema Autenticado. Confirma a senha atual + envia link de confirmação pro novo e-mail. Sempre 202.
POST /auth/email-change/confirm (v0.92.0+) EmailChangeConfirmSchemaEmailChangeResponseSchema Consome o token + aplica o novo e-mail + (opcional) avisa o e-mail antigo.
POST /auth/email-verify/request (v0.92.0+) — → EmailChangeResponseSchema Autenticado. Reenvia link de verificação pro e-mail atual.
POST /auth/email-verify/confirm (v0.92.0+) EmailChangeConfirmSchemaEmailChangeResponseSchema Consome o token + marca a conta ativa.
POST /auth/email-recovery/request (v0.92.0+, opt-in) EmailRecoveryRequestSchemaEmailChangeResponseSchema Não autenticado. Recupera conta cujo e-mail se perdeu: senha (+ MFA se inscrito). Só montado com AUTH_EMAIL_RECOVERY_ENABLED=True. Sempre 202.
POST /auth/refresh (v0.65.0+) RefreshSchemaLoginResponseSchema Troca um refresh token válido por um JWT pair novo. Sem email/senha. Rejeita access token replayado (401) e conta inativa (403).

password-reset/confirm vs password-change — qual é qual?

São fluxos diferentes, não confunda:

  • /auth/password-reset/confirm — o usuário esqueceu a senha. Ele não está logado; prova identidade com o token que recebeu por e-mail. (Veja /auth/password-reset/request antes.)
  • /auth/password-change — o usuário lembra a senha e está logado. Manda o access_token no header Authorization: Bearer … e reconfirma a current_password. Não envolve e-mail nem token de reset. Retorna 204 e os tokens atuais continuam válidos.

Recuperação de senha

O clássico "esqueci minha senha". O usuário não está logado e prova identidade com um token de uso único que chega por e-mail. São dois passos.

Caso base (SPA / JSON)

Passo 1 — pedir o link. O usuário digita o e-mail; o backend sempre responde 202 com a mesma mensagem genérica (não vaza se o e-mail existe):

curl -X POST localhost:8000/auth/password-reset/request \
  -H "Content-Type: application/json" \
  -d '{"email": "ana@example.com"}'
{ "message": "Se o e-mail existir, enviamos um link.", "reset_url": null }

Passo 2 — trocar a senha. O usuário abre o link do e-mail; o front-end lê o token da URL e o envia com a nova senha. Em caso de sucesso a senha é trocada e o usuário já sai logado (vem um JWT pair):

curl -X POST localhost:8000/auth/password-reset/confirm \
  -H "Content-Type: application/json" \
  -d '{"token": "abc123…", "new_password": "nova-senha-forte-12"}'
{
  "user_id": "7d8e4d5a-…",
  "access_token": "eyJhbGciOi…",
  "refresh_token": "eyJhbGciOi…",
  "mfa_required": false,
  "mfa_token": null
}

Erros que você vai ver

  • Token desconhecido / já usado / expirado → 400.
  • new_password viola AUTH_PASSWORD_MIN_LENGTH / complexidade → 422.
  • request nunca erra por e-mail inexistente — sempre 202.

Modo dev: link no body (sem SMTP)

Sem o extra [email], ou com AUTH_RETURN_TOKEN_IN_RESPONSE=True, o reset_url volta no corpo do request em vez de ir por e-mail — você dispara o fluxo inteiro sem inbox. Em produção ele fica null.

Sem front-end? Páginas HTML pelo backend (Modo E)

Com AUTH_BACKEND_LINKS=True o SDK monta GET/POST /auth/password-reset/{token}: o link do e-mail aponta pro backend, que renderiza um formulário HTML, valida e mostra uma página de sucesso/erro — sem nenhuma rota no front-end. Veja Cinco modos de operação (Modo E).

Trocar a senha (logado)

Diferente do reset: aqui o usuário lembra a senha e está logado. Não há token nem e-mail — ele manda o access_token no header e reconfirma a current_password:

curl -X POST localhost:8000/auth/password-change \
  -H "Authorization: Bearer eyJhbGciOi…" \
  -H "Content-Type: application/json" \
  -d '{"current_password": "senha-atual", "new_password": "nova-senha-forte-12"}'

Retorna 204; os tokens atuais continuam válidos (este endpoint não revoga sessões). current_password errada → 401.

Reset vs change — qual é qual?

  • Esqueceu a senha, não está logado → Recuperação de senha (password-reset/requestconfirm, prova com token de e-mail).
  • Lembra a senha, está logado → trocar a senha (password-change, prova com a senha atual no header).

Troca e recuperação de e-mail

O espelho do fluxo de senha, mas pro e-mail da conta (v0.92.0+). Três cenários distintos — comece pelo seu.

Trocar e-mail (logado)

O caso base: o usuário está logado e quer mudar o próprio e-mail. Confirma a current_password e informa o new_email; um link de confirmação vai pro novo endereço. O e-mail só muda quando esse link é confirmado.

# Passo 1 — pedir a troca (autenticado). Sempre 202.
curl -X POST localhost:8000/auth/email-change/request \
  -H "Authorization: Bearer eyJhbGciOi…" \
  -H "Content-Type: application/json" \
  -d '{"new_email": "nova@example.com", "current_password": "senha-atual"}'

# Passo 2 — confirmar (o front lê o token do link enviado ao novo e-mail).
curl -X POST localhost:8000/auth/email-change/confirm \
  -H "Content-Type: application/json" \
  -d '{"token": "abc123…"}'

Ao confirmar, se AUTH_EMAIL_CHANGE_NOTIFY_OLD=True (padrão) um aviso de segurança vai pro endereço antigo — o padrão de bancos/Google, pra uma conta sequestrada ainda alertar o dono.

Erros

  • Senha atual errada → 401.
  • new_email já em uso (no pedido ou na confirmação — corrida) → 409.
  • Token inválido / expirado / usado → 400.

Re-verificar o e-mail atual

Reenvia um link de verificação pro endereço atual — útil quando o e-mail de ativação se perdeu. Autenticado, sem trocar nada; confirmar o link marca a conta ativa.

curl -X POST localhost:8000/auth/email-verify/request \
  -H "Authorization: Bearer eyJhbGciOi…"
# depois: POST /auth/email-verify/confirm  {"token": "…"}

Recuperação de e-mail

O "esqueci / perdi acesso ao meu e-mail". Endpoint não autenticado e opt-in — só é montado com AUTH_EMAIL_RECOVERY_ENABLED=True. O usuário prova identidade com a senha (e um código MFA quando o TOTP está inscrito) e informa o novo endereço; o link de confirmação vai pro novo e-mail e reusa o mesmo email-change/confirm.

# Só existe se AUTH_EMAIL_RECOVERY_ENABLED=True. Sempre 202 (anti-enumeração).
curl -X POST localhost:8000/auth/email-recovery/request \
  -H "Content-Type: application/json" \
  -d '{
        "email": "antigo@example.com",
        "new_email": "novo@example.com",
        "current_password": "senha-atual",
        "mfa_code": "123456"
      }'
# depois: POST /auth/email-change/confirm  {"token": "…"}  (link foi pro novo e-mail)

Recuperação é sensível — ligue com cuidado

Esse endpoint deixa quem tem a senha mover a conta pra outro e-mail sem acessar a caixa antiga — vetor de account takeover se a senha vazou. Regras:

  • Opt-in: fica desligado até AUTH_EMAIL_RECOVERY_ENABLED=True.
  • Sempre 202 genérico pra toda falha leve (e-mail desconhecido, senha errada, mfa_code faltando/errado) — não enumera contas.
  • Se o TOTP estiver inscrito, exige um mfa_code válido.
  • Mantenha AUTH_EMAIL_CHANGE_NOTIFY_OLD=True pra o e-mail antigo sempre ser avisado da troca.

Onde o novo e-mail fica guardado

O endereço pendente viaja na coluna payload do token (EMAIL_CHANGE), não no UserModel. A confirmação relê o payload, re-checa que o endereço ainda está livre (corrida entre pedido e confirmação → 409) e só então grava. Veja a nota de migração da 0.92.0.

Renovando a sessão com o refresh token

O access_token é curto por design (JWT_ACCESS_TTL_SECONDS, 1 h por padrão). Quando ele expira, não force o usuário a logar de novo — troque o refresh_token (longo, 7 dias por padrão) por um par novo em POST /auth/refresh:

curl -X POST localhost:8000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "eyJhbGciOi…"}'
{
  "user_id": "7d8e4d5a-9f4b-4c3a-bd0a-1234567890ab",
  "access_token": "eyJhbGciOi…(novo)",
  "refresh_token": "eyJhbGciOi…(novo)",
  "mfa_required": false,
  "mfa_token": null
}

O endpoint decodifica o token, exige que ele realmente carregue a claim refresh (um access token replayado aqui é rejeitado com 401), resolve o sub pra um usuário ativo e emite um par novo.

Os dois tokens rotacionam

A resposta traz um refresh_token novo. Persista esse e descarte o que você enviou. No modo stateless (default) o SDK emite JWTs, então o par antigo não é revogado — ele continua válido até o próprio exp.

Precisa de revogação real? O SDK já entrega

Não escreva sua própria tabela de refresh tokens — desde a v0.66.0 o SDK oferece refresh tokens DB-backed opacos com rotação single-use, detecção de reuso (token roubado revoga a família inteira) e POST /auth/logout. É opt-in: passe um refresh_token_model pro UserAuthService. Veja a receita Refresh tokens (rotação/revogação).

Quando o refresh token também expira

Aí não tem renovação possível — o 401 é definitivo e o cliente cai de volta no POST /auth/login com email + senha.

Sem frontend? O método de serviço por trás do endpoint é público — await service.refresh_tokens(session, refresh_token=...) devolve (user, access_token, refresh_token).


Settings — variáveis de ambiente

Toda a config do flow vem de mixins de settings. Mixe-as na sua classe Settings:

# src/core/settings.py
from tempest_fastapi_sdk import (
    AuthSettings,
    BaseAppSettings,
    DatabaseSettings,
    EmailSettings,
    JWTSettings,
    ServerSettings,
)


class Settings(
    ServerSettings,
    DatabaseSettings,
    EmailSettings,
    JWTSettings,
    AuthSettings,
    BaseAppSettings,
):
    pass


settings: Settings = Settings()

Nome do atributo é o nome da env var

Cada atributo das tabelas abaixo é lido de uma variável de ambiente de mesmo nome, case-sensitive, sem prefixo. AUTH_PASSWORD_MIN_LENGTH no .envsettings.AUTH_PASSWORD_MIN_LENGTH. Todos têm default — você só seta o que quer mudar.

As variáveis se dividem em dois mixins e seis grupos de concern. Elas estão separadas de propósito: senha não é a mesma coisa que e-mail, e autenticação (JWT) não é a mesma coisa que ativação de conta.

Grupo 1 — Autenticação / JWT (JWTSettings)

Controla a assinatura e validade dos tokens que o login devolve. É o mesmo JWT_SECRET que a dependency get_current_user usa pra validar (veja Pegando o current_user).

Env var Tipo Default O que faz
JWT_SECRET str (≥32 bytes) change-me-…-32 Segredo HMAC que assina o JWT. Obrigatório trocar em produção.
JWT_ALGORITHM str HS256 Algoritmo JOSE. HS256/HS512 (segredo simétrico) ou RS256 (par de chaves).
JWT_ACCESS_TTL_SECONDS int (≥1) 3600 Validade do access token (1 h). Curto por design — renove via refresh.
JWT_REFRESH_TTL_SECONDS int (≥1) 604800 Validade do refresh token (7 dias).
JWT_ISSUER str | None None Claim iss. None omite o claim.

JWT_SECRET default vaza tokens

O default change-me-change-me-change-me-32 existe só pra subir local. Em produção, qualquer um com o default consegue forjar um JWT válido. Gere um segredo forte (openssl rand -base64 48) e injete por secret manager — nunca commite.

Grupo 2 — Política de senha (AuthSettings)

Env var Tipo Default O que faz
AUTH_PASSWORD_MIN_LENGTH int (≥1) 12 Comprimento mínimo aceito no signup e no reset.
AUTH_PASSWORD_MAX_BYTES int (≥1) 72 Comprimento máximo em bytes UTF-8. É o limite duro do bcrypt.
AUTH_PASSWORD_REQUIRE_COMPLEXITY bool false true = exige 1 minúscula + 1 maiúscula + 1 dígito + 1 caractere especial.

Os dois interagem — é aqui que costuma confundir. A regra exata:

  • complexity=false (default): só o comprimento importa. Qualquer senha com ≥ AUTH_PASSWORD_MIN_LENGTH caracteres passa, sem exigência de composição.
  • complexity=true: além das 4 classes de caracteres, o piso de comprimento efetivo vira max(AUTH_PASSWORD_MIN_LENGTH, 8). Ou seja, um AUTH_PASSWORD_MIN_LENGTH abaixo de 8 é ignorado enquanto a complexidade está ligada.

Tabela de decisão:

MIN_LENGTH REQUIRE_COMPLEXITY Senha aceita quando
12 false ≥ 12 chars, qualquer composição
4 false ≥ 4 chars, qualquer composição (piso baixo, dev-only)
4 true ≥ 8 chars (piso 8 sobrescreve o 4) + as 4 classes
16 true ≥ 16 chars + as 4 classes

O piso é fonte única da verdade

Os schemas de request (SignupSchema, PasswordResetConfirmSchema) não impõem limite próprio de comprimento — eles delegam pra essas duas vars. Baixar AUTH_PASSWORD_MIN_LENGTH pra 4 realmente afrouxa a validação na rota também. Não há um segundo limite escondido no schema "te protegendo".

O teto conta bytes, não caracteres

AUTH_PASSWORD_MAX_BYTES existe porque bcrypt recusa entrada acima de 72 bytes: hashpw levanta ValueError, e sem esse teto o erro subia como 500 no signup / reset / troca de senha. Bytes é a unidade que o hash vê, e 72 bytes chegam bem antes de 72 caracteres em texto não-ASCII — um emoji custa 4 bytes, uma letra acentuada 2, então "🔒" * 19 (19 caracteres) já passa do limite e responde 422. Só aumente o valor se você trocar o hasher por um sem esse limite.

Grupo 3 — Controle do fluxo de e-mail (AuthSettings)

Decidem se e como o link chega ao usuário. Mapeiam direto nos cinco modos de operação.

Env var Tipo Default O que faz
AUTH_AUTO_ACTIVATE bool false true = user nasce ativo, pula activation, signup devolve JWT pair direto (Modo D). Nunca em produção.
AUTH_RETURN_TOKEN_IN_RESPONSE bool false true = link de ativação/reset vai no corpo JSON em vez do e-mail (Modo C).

Grupo 4 — TTL dos tokens de conta (AuthSettings)

Validade dos tokens de uso único (ativação / reset) — distintos dos JWT do grupo 1.

Env var Tipo Default O que faz
AUTH_ACTIVATION_TTL_SECONDS int (≥60) 604800 Validade do token de ativação (7 dias).
AUTH_PASSWORD_RESET_TTL_SECONDS int (≥60) 3600 Validade do token de reset (1 h). Curto é mais seguro.

Grupo 5 — URLs e templates de e-mail (AuthSettings)

Env var Tipo Default O que faz
AUTH_ACTIVATION_URL_TEMPLATE str http://localhost:3000/activate?token={token} URL que vai no e-mail; {token} é substituído. Aponta pro frontend (exceto no Modo E).
AUTH_PASSWORD_RESET_URL_TEMPLATE str http://localhost:3000/reset-password?token={token} Idem, para reset.
AUTH_ACTIVATION_TEMPLATE str activation.html Nome do arquivo Jinja2 do HTML do e-mail de ativação, resolvido no template_dir do EmailUtils.
AUTH_PASSWORD_RESET_TEMPLATE str password_reset.html Idem, para reset.

URL template ≠ Jinja2 template

*_URL_TEMPLATE é uma string .format() com {token} — é o link. *_TEMPLATE é o nome de um arquivo .html — é o e-mail que embrulha o link. Confundir os dois é o erro nº 1. Detalhe completo em Anatomia de um e-mail.

Grupo 6 — Páginas renderizadas pelo backend (Modo E, AuthSettings)

Só relevantes quando AUTH_BACKEND_LINKS=true. Veja o Modo E para o fluxo completo.

Env var Tipo Default O que faz
AUTH_BACKEND_LINKS bool false true = monta 5 endpoints HTML extras; o link do e-mail aponta pro backend, não pro frontend.
AUTH_LOGIN_URL str | None None URL de login no botão "Ir pro login" das páginas de sucesso. None esconde o botão.
AUTH_ACTIVATION_SUCCESS_TEMPLATE str activation_success.html Página HTML de ativação OK.
AUTH_ACTIVATION_ERROR_TEMPLATE str activation_error.html Página HTML de ativação com erro.
AUTH_PASSWORD_RESET_FORM_TEMPLATE str password_reset_form.html Form HTML de nova senha.
AUTH_PASSWORD_RESET_SUCCESS_TEMPLATE str password_reset_success.html Página HTML de reset OK.
AUTH_PASSWORD_RESET_ERROR_TEMPLATE str password_reset_error.html Página HTML de reset com erro.

Grupo 7 — Idioma dos e-mails e páginas (AuthSettings)

Env var Tipo Default O que faz
AUTH_DEFAULT_LOCALE str pt-BR Idioma dos e-mails e páginas HTML bundled. Aceita pt-BR e en-US (normalizado: PT-BR, pt_br, ptbrpt-BR).

Tem uma seção inteira só pra isso, explicada bem devagar: Idioma dos e-mails e páginas (i18n).

Env var Tipo Default O que faz
AUTH_TOKEN_DELIVERY "bearer" | "cookie" | "both" bearer Como o login/refresh devolvem o par JWT. Veja Entrega de token.
AUTH_COOKIE_SECURE bool true Marca os cookies como Secure (só trafegam via HTTPS). Desligue só em HTTP puro — senão o browser descarta o cookie.
AUTH_COOKIE_SAMESITE "lax" | "strict" | "none" lax SPA cross-site precisa none (+ Secure=true).
AUTH_COOKIE_DOMAIN str | None None Domain do cookie. None = host exato. Use .example.com pra compartilhar entre subdomínios.
AUTH_ACCESS_COOKIE_NAME str access_token Nome do cookie do access token.
AUTH_REFRESH_COOKIE_NAME str refresh_token Nome do cookie do refresh token (escopado ao path do endpoint de refresh).

MFA / TOTP tem suas próprias vars

Quando AUTH_MFA_ENABLED=true, o AuthSettings ainda expõe AUTH_MFA_ISSUER, AUTH_MFA_RECOVERY_CODES_COUNT, AUTH_MFA_TOKEN_TTL_SECONDS e AUTH_MFA_VERIFY_WINDOW. Ficam fora do escopo desta receita (signup/activate/login/reset) — são cobertos na receita de MFA.


Anatomia de um e-mail

Três conceitos diferentes que parecem o mesmo. Eis o que cada um faz, exatamente uma vez, em pseudo-código:

1. SDK gera um token opaco aleatório (string de 64 chars).
2. AUTH_ACTIVATION_URL_TEMPLATE.format(token=…)  →  link com o token embutido.
3. Renderiza AUTH_ACTIVATION_TEMPLATE (Jinja2 HTML) passando { user, activation_url, expires_at, expires_at_str }.
4. EmailUtils.send(to=user.email, subject=..., html=<HTML renderizado>).

Em prosa:

  • Token opaco — string aleatória que o SDK gera, hasheia (SHA-256) e grava na tabela user_tokens. O plaintext sai pelo e-mail uma única vez; o banco só guarda o hash.
  • URL template (AUTH_ACTIVATION_URL_TEMPLATE) — formato literal pra montar a URL que vai pro usuário clicar. Aponta pro frontend, não pro backend. O frontend recebe ?token=…, capta da query string e chama POST /auth/activate/{token} no backend.
  • Jinja2 template (AUTH_ACTIVATION_TEMPLATE) — nome do arquivo HTML dentro do template_dir do EmailUtils. É o HTML do e-mail, não a URL. Recebe o contexto { user, activation_url, expires_at, expires_at_str } e renderiza o markup final. Use {{ expires_at_str }} no template — é a data de expiração já formatada e curta (ex.: 21/06/2026 23:25 (UTC), sem segundos); expires_at continua disponível como datetime cru se você quiser formatar do seu jeito.

URL template ≠ Jinja2 template

AUTH_ACTIVATION_URL_TEMPLATE é uma string Python .format()-style — só tem o placeholder {token}. Não confunda com o arquivo .html que o Jinja2 renderiza. A URL formatada é injetada como variável no contexto do Jinja2 sob o nome activation_url, e o template HTML embrulha ela num botão.

Fluxo visual:

sequenceDiagram
    participant U as Usuário
    participant F as Frontend
    participant API as Backend (SDK)
    participant E as SMTP
    participant DB as Banco

    U->>F: preenche email + senha
    F->>API: POST /auth/signup
    API->>DB: INSERT user (is_active=false) + INSERT token (hash, TTL)
    API->>API: token plaintext + AUTH_ACTIVATION_URL_TEMPLATE.format(token=…)
    API->>API: render Jinja2 (user, activation_url, expires_at)
    alt AUTH_RETURN_TOKEN_IN_RESPONSE=false
        API->>E: SMTP send (HTML renderizado)
        API->>F: 201 + {message: "check your email"}
    else AUTH_RETURN_TOKEN_IN_RESPONSE=true
        API->>F: 201 + {activation_url: "https://app/activate?token=..."}
    end
    Note right of F: usuário (ou dev) abre a URL
    F->>API: POST /auth/activate/{token}
    API->>DB: hash(token) match? expirou? já usou?
    API->>F: 200 + JWT pair

Idioma dos e-mails e páginas (i18n)

Desde a v0.59.0, os e-mails e as páginas HTML que o SDK já traz prontos falam dois idiomas: 🇧🇷 Português do Brasil (pt-BR) — que é o padrão — e 🇺🇸 Inglês americano (en-US). Você não precisa criar nenhum template pra isso funcionar. 🚀

A regra de ouro (decore só isso)

Existem duas coisas diferentes que escolhem o idioma, e elas funcionam de jeitos diferentes. Preste atenção:

O que Como o idioma é escolhido
E-mails (ativação, reset) Sempre usam AUTH_DEFAULT_LOCALE. Ponto final.
Páginas HTML (Modo E, backend) Usam o Accept-Language do navegador do usuário; se o navegador não disser nada, caem no AUTH_DEFAULT_LOCALE.

Por que o e-mail não negocia idioma?

Quando o SDK monta o e-mail, não existe nenhum navegador pedindo nada — é um processo de servidor mandando uma mensagem. Não tem de onde "adivinhar" o idioma. Por isso o e-mail é sempre fixo no AUTH_DEFAULT_LOCALE. Já a página HTML é aberta por um navegador de verdade, que manda o cabeçalho Accept-Language dizendo "eu prefiro português" — aí dá pra respeitar a preferência da pessoa.

Passo 1 — escolher o idioma padrão

Só uma variável de ambiente. É isso:

# .env
AUTH_DEFAULT_LOCALE=pt-BR   # padrão — pode até omitir

Quer tudo em inglês? Troque por:

AUTH_DEFAULT_LOCALE=en-US

Não precisa acertar a caixa/formato exato

O valor é normalizado pra você. Tudo isso vira pt-BR: pt-BR, PT-BR, pt_br, ptbr, pt. E tudo isso vira en-US: en-US, EN_us, enus, en. Se você digitar algo que o SDK não conhece (tipo klingon), ele cai no padrão pt-BR em vez de quebrar.

Passo 2 — (opcional) deixar a página HTML seguir o navegador

Isso já vem ligado de graça no Modo E (AUTH_BACKEND_LINKS=true). Você não faz nada. Quando o usuário clica no link do e-mail e o navegador dele está em português, ele vê a página em português; se estiver em inglês, vê em inglês. Se o navegador não mandar Accept-Language, a página usa o AUTH_DEFAULT_LOCALE.

Navegador em pt-BR  →  Accept-Language: pt-BR  →  página em Português
Navegador em en-US  →  Accept-Language: en-US  →  página em Inglês
Navegador sem header →  cai no AUTH_DEFAULT_LOCALE

Passo 3 — (opcional) traduzir/customizar você mesmo

Os templates bundled ficam em subpastas por idioma (pt-BR/, en-US/). Pra trocar o texto/visual de um idioma, crie um arquivo de mesmo nome na subpasta certa do seu template_dir (ex.: template_dir/pt-BR/activation_success.html). A ordem de busca completa está na dica "Override por idioma" mais abaixo, no Modo E.

Bônus — data de expiração curta e legível

Antes, o e-mail mostrava a expiração crua e feia assim:

This link expires at 2026-06-21 23:25:49.742054+00:00

Agora o SDK injeta no template a variável expires_at_str, já formatada e sem segundos, no formato do idioma:

Idioma Como aparece
pt-BR 21/06/2026 23:25 (UTC)
en-US 2026-06-21 23:25 (UTC)

Nos seus templates custom, use {{ expires_at_str }} (curto e bonito). Se quiser formatar do seu jeito, o datetime cru ainda está disponível em {{ expires_at }}.

Recapitulando

  • Uma variável manda no idioma dos e-mails: AUTH_DEFAULT_LOCALE.
  • Páginas HTML seguem o navegador (Accept-Language) e caem no AUTH_DEFAULT_LOCALE quando não há header.
  • Padrão é pt-BR. Coloque en-US se quiser inglês.
  • Use {{ expires_at_str }} pra mostrar a expiração sem segundos.

Cinco modos de operação

Modo Quando usar Flags Onde o link aparece
A. Produção (SPA) SaaS público, e-mail real, frontend SPA dono das páginas AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
AUTH_BACKEND_LINKS=false
SMTP real (Mailgun, SES, Postmark…)
Inbox real → frontend processa o token
B. Dev local com SMTP fake Desenvolvimento diário sem mandar e-mail real AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP apontando pra Mailhog (localhost:1025) ou smtp4dev (localhost:2525)
UI web do Mailhog/smtp4dev em localhost:8025 / localhost:5000
C. Dev sem SMTP Validação rápida sem subir nenhum container de e-mail AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=true
email=None ou SMTP inválido
Body da resposta HTTP do signup
D. CI / testes Suite de testes que não exercita activation AUTH_AUTO_ACTIVATE=true Nenhum — signup já devolve JWT pair
E. Backend-only (v0.32.0+) Quer 100% de controle no backend — sem responsabilidade no frontend. Ideal pra APIs sem SPA, MVPs, intranets. AUTH_BACKEND_LINKS=true
URL templates apontam pro backend (https://api.example.com/auth/activate/{token})
AUTH_LOGIN_URL=https://app.example.com/login (opcional, mostra botão "Ir pro login" nas páginas HTML)
Backend renderiza HTML success/error direto — usuário só clica no link do e-mail

Modo A — produção

AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USERNAME=postmaster@mg.example.com
SMTP_PASSWORD=...                          # secret, não commitar
SMTP_FROM_ADDR=noreply@example.com
AUTH_ACTIVATION_URL_TEMPLATE=https://app.example.com/activate?token={token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=https://app.example.com/reset?token={token}

Fluxo: signup → e-mail real chega no inbox → usuário clica → frontend chama POST /auth/activate/{token} → login.

Modo B — dev com SMTP local (Mailhog ou smtp4dev)

Mesmo .env do modo A, mas apontando o SMTP para um container local que intercepta os e-mails em vez de mandar de verdade. Use este modo no dia-a-dia — o fluxo é idêntico ao de produção, então você pega bugs de template, encoding, charset, etc. ao mesmo tempo que evita spammar e-mail real.

# .env.dev
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP_HOST=localhost
SMTP_PORT=1025                             # Mailhog SMTP padrão
SMTP_USERNAME=                             # vazio — Mailhog não autentica
SMTP_PASSWORD=
SMTP_FROM_ADDR=dev@local
AUTH_ACTIVATION_URL_TEMPLATE=http://localhost:5173/activate?token={token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=http://localhost:5173/reset?token={token}

Abra http://localhost:8025 (Mailhog) ou http://localhost:5000 (smtp4dev) pra ver os e-mails interceptados. Veja a seção Mailhog vs smtp4dev abaixo.

Sem container de SMTP nenhum. O signup devolve o link de ativação no JSON da resposta:

AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=true
AUTH_ACTIVATION_URL_TEMPLATE=http://localhost:5173/activate?token={token}

Request:

curl -X POST localhost:8000/auth/signup \
  -H 'content-type: application/json' \
  -d '{"email":"dev@local","password":"abcdefghijkl","name":"Dev"}'

Resposta (formato real do SignupResponseSchema):

{
  "user_id": "0193e9ea-7c4b-7c8e-bc05-2a3a8d9f7e10",
  "activation_required": true,
  "activation_url": "http://localhost:5173/activate?token=aBcD...xYz",
  "access_token": null,
  "refresh_token": null
}

Cole a URL no navegador / curl pra exercitar POST /auth/activate/{token}.

Modo D — CI / testes (skip total)

AUTH_AUTO_ACTIVATE=true

Signup pula ativação inteira e devolve {access_token, refresh_token} direto. Use só em testes ou quando o produto for interno e cada usuário já é confiável.

Modo E — backend-only (v0.32.0+)

Quando você prefere que toda a experiência do link aconteça no backend, sem nenhuma página no frontend, ative AUTH_BACKEND_LINKS=True. O router passa a montar cinco endpoints HTML adicionais — GET /auth/activate/{token}, GET /auth/password-reset/{token}, POST /auth/password-reset/{token} (form-encoded), GET /auth/email-change/{token} e GET /auth/email-verify/{token}. O e-mail aponta o usuário direto pra esses endpoints; o backend ativa a conta / processa o reset / renderiza HTML success ou error — usando templates Jinja2 bundled que você pode shadowar.

# .env — Modo E (backend-only)
AUTH_BACKEND_LINKS=true
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false

# IMPORTANTE: URL templates apontam pro BACKEND, não pro frontend.
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/auth/activate/{token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=https://api.example.com/auth/password-reset/{token}

# Opcional: URL do seu login. Quando setado, aparece um botão "Ir pro login"
# nas páginas de success/error renderizadas pelo backend. Quando null,
# o botão é omitido (puro server-side, zero acoplamento com frontend).
AUTH_LOGIN_URL=https://app.example.com/login

SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_FROM_ADDR=noreply@example.com

Link dá 404? Alinhe o template com o prefixo onde o router está montado

Os endpoints acima são relativos ao ponto onde você monta o make_auth_router. Se você inclui o router com um prefixo — comum pra separar rotas de negócio:

app.include_router(make_auth_router(...), prefix="/api")

então a rota real de ativação vira GET /api/auth/activate/{token}, não /auth/activate/{token}. Mas o AUTH_ACTIVATION_URL_TEMPLATE é uma string literal — ele não sabe do prefixo. Se o template apontar pra .../auth/activate/{token} (sem /api), o link do e-mail bate numa rota inexistente e responde 404, mesmo com o signup retornando 201.

# ❌ router montado com prefix="/api", mas o template não tem /api → 404
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/auth/activate/{token}

# ✅ template alinhado com o prefixo real de montagem
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/api/auth/activate/{token}

Duas checagens ao configurar o Modo E: (1) o host é o domínio público do backend (nunca localhost — o link roda no browser do usuário, não no servidor); (2) o path inclui todo prefixo com que você montou o router.

Fluxo:

sequenceDiagram
    participant U as Usuário
    participant E as Inbox
    participant API as Backend
    participant DB as Banco

    U->>API: POST /auth/signup
    API->>DB: INSERT user (is_active=false) + token (hash, TTL)
    API->>E: e-mail com link https://api.example.com/auth/activate/{token}
    U->>E: clica no link
    E->>API: GET /auth/activate/{token}
    API->>DB: hash(token) válido? não-usado? não-expirado?
    alt token válido
        API->>DB: is_active=true + token.used_at=now
        API->>U: HTML activation_success.html (botão "Ir pro login" se AUTH_LOGIN_URL setado)
    else token inválido / expirado
        API->>U: HTML activation_error.html (HTTP 400)
    end

Password reset segue padrão similar: GET renderiza form HTML; POST (form-encoded) consome o token e renderiza success/error.

Templates HTML bundled (override droppando o mesmo nome no template_dir):

Template Endpoint que renderiza Variáveis Jinja2 disponíveis
activation_success.html GET /auth/activate/{token} (sucesso) user, login_url
activation_error.html GET /auth/activate/{token} (falha) reason, login_url
password_reset_form.html GET /auth/password-reset/{token} user, form_action, min_length, error, login_url
password_reset_success.html POST /auth/password-reset/{token} (sucesso) user, login_url
password_reset_error.html POST /auth/password-reset/{token} (token inválido) reason, login_url

Pra override: passe template_dir no make_auth_router e crie arquivos de mesmo nome.

from fastapi import FastAPI

from tempest_fastapi_sdk import UserAuthService, make_auth_router

from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel

auth_service = UserAuthService(
    user_model=UserModel,
    token_model=UserTokenModel,
    auth_settings=settings,
    jwt_settings=settings,
)
app = FastAPI()


app.include_router(
    make_auth_router(
        auth_service,
        session_factory=db.session_dependency,
        template_dir="src/templates/auth",   # opcional
    ),
)

Override por idioma (desde v0.59.0)

Os templates bundled agora moram em subpastas por idioma (pt-BR/ e en-US/). Você tem duas formas de override, e o SDK procura nesta ordem (a primeira que existir vence):

  1. template_dir/<locale>/activation_success.html — override só daquele idioma (ex.: src/templates/auth/pt-BR/...).
  2. template_dir/activation_success.html — override flat, vale pra todos os idiomas (compatível com versões anteriores à 0.59.0; continua funcionando sem mudar nada).

Ou seja: se você já tinha templates flat, não precisa mexer em nada. Se quiser um visual diferente por idioma, crie a subpasta.

Trade-offs do Modo E:

  • Zero dependência do frontend — backend é fonte única da verdade do fluxo de auth.
  • MVP em minutos — sem precisar criar rotas SPA pra processar tokens.
  • Funciona em projetos sem frontend — APIs públicas, intranets, ferramentas internas.
  • ⚠️ JWT não é entregue automaticamente — após ativação, o usuário precisa fazer login manualmente (clicando em "Ir pro login" e usando as credenciais). Por design: zero leak de token em URL, history, ou server logs.
  • ⚠️ Requer [email] extra (Jinja2) pra renderizar as páginas HTML — mesma dependência do template de e-mail.
  • ⚠️ CSRF na form de reset — o form HTML usa POST tradicional sem token CSRF. Aceita a request porque o token de reset é one-shot + TTL curto + bound a um user específico, mas considere acoplar CSRFMiddleware se atacantes conseguirem prever URLs ativas.

Os endpoints JSON (POST /auth/activate/{token}, POST /auth/password-reset/confirm) continuam montados — você pode usar Modo E + manter SPA endpoints ao mesmo tempo.


Entrega de token

(v0.87.0+)

Por padrão o login devolve access_token / refresh_token no corpo da resposta e o cliente reenvia como Authorization: Bearer <token>. É ótimo pra apps mobile/API, mas um SPA no browser precisa guardar o token em algum lugar acessível por JS — exposto a XSS. O AUTH_TOKEN_DELIVERY deixa você escolher.

Modo O que muda Pra quem
bearer (default) Tokens só no body. Comportamento histórico, retrocompatível. Mobile, APIs, clientes que mandam Authorization.
cookie Tokens setados como cookies HttpOnly nos mesmos paths (/auth/login, /auth/refresh, /auth/logout); o body volta com os tokens null. SPA no browser — o token nunca fica visível pro JS (defesa contra XSS).
both Endpoints bearer ficam em /auth/* e um conjunto cookie paralelo é montado em /auth/cookie/*. Um backend que serve web (cookie) e mobile (bearer) ao mesmo tempo.

Cookie Secure exige HTTPS

Com AUTH_COOKIE_SECURE=true (default) o browser reenvia o cookie por HTTPS. Servindo o backend em HTTP puro, o cookie é descartado e a sessão nunca persiste (login parece funcionar mas nada fica logado). Em produção, ponha TLS na frente e mantenha true; em HTTP local de dev, AUTH_COOKIE_SECURE=false.

# .env
AUTH_TOKEN_DELIVERY=cookie
AUTH_COOKIE_SECURE=true          # false só em HTTP puro
AUTH_COOKIE_SAMESITE=lax         # "none" (+Secure) se o SPA for cross-site
from fastapi import FastAPI

from tempest_fastapi_sdk import UserAuthService, make_auth_router

from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel

auth_service = UserAuthService(
    user_model=UserModel,
    token_model=UserTokenModel,
    auth_settings=settings,
    jwt_settings=settings,
)
app = FastAPI()


app.include_router(make_auth_router(auth_service, session_factory=db.session_dependency))

Fluxo no frontend — não guarda token nenhum, só chama os endpoints com credentials: "include":

// login: o browser grava os cookies HttpOnly sozinho
await fetch("/auth/login", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password }),
});

// requests autenticadas: o cookie access viaja automático
await fetch("/api/me", { credentials: "include" });

// renovar sessão: o cookie refresh é lido no backend, sem body
await fetch("/auth/refresh", { method: "POST", credentials: "include" });

// logout: limpa os cookies (e revoga o refresh se houver refresh_token_model)
await fetch("/auth/logout", { method: "POST", credentials: "include" });

A dependency de current_user (veja Pegando o current_user) passa a ler o access token do cookie automaticamente quando a entrega envolve cookie — o header Authorization ainda tem prioridade se vier.

Modo both

AUTH_TOKEN_DELIVERY=both

Monta os dois conjuntos, sem colisão de rota:

# bearer (body):
POST /auth/login
POST /auth/refresh
POST /auth/logout

# cookie (HttpOnly):
POST /auth/cookie/login
POST /auth/cookie/refresh
POST /auth/cookie/logout

O que continua no body

A entrega cookie cobre o ciclo login / refresh / logout. Ativação (POST /auth/activate/{token}), auto-login no signup (AUTH_AUTO_ACTIVATE) e o POST /auth/mfa/verify ainda retornam o par JWT no body, independente do AUTH_TOKEN_DELIVERY.

Ajuste fino dos cookies: cookie_config=

As settings AUTH_COOKIE_* cobrem o caso comum. Quando você precisa de controle por atributo — nome, max_age, path do refresh, Domain — passe um AuthCookieConfig no make_auth_router; ele ganha das settings:

from tempest_fastapi_sdk import AuthCookieConfig, make_auth_router

app.include_router(
    make_auth_router(
        auth_service,
        session_factory=db.session_dependency,
        token_delivery="cookie",
        cookie_config=AuthCookieConfig(
            access_name="session",
            refresh_name="session_refresh",
            access_max_age=900,
            refresh_max_age=1209600,
            samesite="none",
            secure=True,
            domain=".exemplo.com",
        ),
    ),
)

Espelhe access_max_age no JWT_ACCESS_TTL_SECONDS e refresh_max_age no JWT_REFRESH_TTL_SECONDS: cookie que vive mais que o token deixa o browser mandando credencial morta, e o inverso desloga antes da hora. O refresh_path é estreitado pelo router pro endpoint de refresh, então o token longo não viaja em request comum. http_only=True é o ponto do modo cookie — não desligue.

Fora do make_auth_router, as mesmas regras valem via apply_auth_cookies(response, access_token=..., refresh_token=..., config=...) e clear_auth_cookies(response, config=...) — útil em rota de login própria (o callback de OAuth da receita de login social, por exemplo).

CORS com credenciais

Pra um SPA em outra origem enviar/receber cookies, o backend precisa de allow_credentials=True no CORS e AUTH_COOKIE_SAMESITE=none + AUTH_COOKIE_SECURE=true (portanto HTTPS). Same-origin (frontend servido do mesmo domínio da API) roda com o default lax.


Mailhog vs smtp4dev

Os dois interceptam SMTP local e renderizam os e-mails numa UI web. Diferenças relevantes:

Aspecto Mailhog smtp4dev
Imagem Docker mailhog/mailhog:latest rnwood/smtp4dev:latest
Porta SMTP padrão 1025 2525 (configurável)
Porta da UI 8025 5000
Tamanho da imagem ~10 MB ~120 MB (.NET)
Multi-conta / multi-inbox não — uma única caixa sim — filtra por destinatário
API HTTP / REST sim (/api/v2/messages) sim (Swagger built-in)
Validação de DKIM / SPF não sim
Manutenção upstream arquivado em 2020, ainda funciona ativa

Sugestão: comece com Mailhog (mais leve, zero-config) e migre pra smtp4dev quando precisar de multi-inbox ou inspeção de DKIM. Para o ciclo signup → activate → reset, Mailhog é suficiente.

docker-compose.yaml — Mailhog

services:
  mailhog:
    image: mailhog/mailhog:latest
    container_name: mailhog
    ports:
      - "1025:1025"  # SMTP — aponte SMTP_HOST aqui
      - "8025:8025"  # UI web

SMTP_PORT=1025, abra http://localhost:8025.

docker-compose.yaml — smtp4dev

services:
  smtp4dev:
    image: rnwood/smtp4dev:latest
    container_name: smtp4dev
    ports:
      - "2525:25"     # SMTP — aponte SMTP_HOST aqui
      - "5000:80"     # UI web
    environment:
      - ServerOptions__HostName=smtp4dev

SMTP_PORT=2525, abra http://localhost:5000.

tempest generate --docker já inclui o Mailhog

Quando o projeto fixa o extra [email], o tempest generate --docker anexa automaticamente um serviço Mailhog ao docker-compose.yaml — sem colar bloco na mão e sem flag --with mailhog. Os blocos acima ficam só como referência, ou para quem prefere smtp4dev.


Customizando templates

O SDK ship dois templates Jinja2 bundled (activation.html + password_reset.html) — HTML responsivo, inline styles, mobile-friendly. Você nunca precisa mexer neles pra um MVP funcionar. Quando quiser branding próprio, basta criar um arquivo com o mesmo nome dentro do template_dir que você passou no EmailUtils:

emails/                            # ← template_dir="emails"
├── activation.html                # override do default do SDK
└── password_reset.html            # override do default do SDK

EmailUtils usa um ChoiceLoader interno do Jinja2 que procura primeiro no seu diretório e só cai no template bundled se não achar. Você pode sobrescrever um, o outro, ou ambos — sem precisar copiar o template inteiro.

Variáveis disponíveis no contexto Jinja2

Variável Tipo Em quais templates Exemplo
user instância de UserModel ambos {{ user.email }}, {{ user.name }} (quando seu modelo expõe a coluna)
activation_url str activation.html https://app.example.com/activate?token=aBcD...xYz
reset_url str password_reset.html https://app.example.com/reset?token=aBcD...xYz
expires_at datetime (UTC, timezone-aware) ambos o valor cru, se quiser formatar do seu jeito
expires_at_str str ambos recomendado — já formatado e curto, sem segundos: 21/06/2026 23:25 (UTC)

Prefira expires_at_str

Use {{ expires_at_str }} em vez de {{ expires_at }} — os templates bundled fazem isso. Ele já vem localizado (conforme AUTH_DEFAULT_LOCALE) e sem os segundos/microssegundos barulhentos. O expires_at cru continua disponível se você precisar de um formato próprio.

Exemplo: emails/activation.html enxuto

<!doctype html>
<html lang="pt-BR">
  <body style="font-family: sans-serif; max-width: 480px; margin: auto;">
    <h1>Bem-vindo(a){% if user.name %}, {{ user.name }}{% endif %}!</h1>
    <p>Para ativar sua conta, clique no botão abaixo:</p>
    <p>
      <a href="{{ activation_url }}"
         style="display: inline-block; padding: 12px 24px;
                background: #4f46e5; color: white;
                text-decoration: none; border-radius: 6px;">
        Ativar conta
      </a>
    </p>
    <p style="color: #6b7280; font-size: 12px;">
      Link válido até {{ expires_at_str }}.
      Se você não criou esta conta, ignore este e-mail.
    </p>
  </body>
</html>

O Jinja2 só roda quando há e-mail real

Nos modos C (AUTH_RETURN_TOKEN_IN_RESPONSE=true) e D (AUTH_AUTO_ACTIVATE=true) o template Jinja2 não é renderizado — o link sai cru no JSON, sem HTML. Só os modos A e B (e-mail SMTP real ou interceptado) exercitam o template.


Segurança

  • Token armazenado como hash SHA-256. O plaintext sai pelo e-mail uma única vez; o banco nunca tem como reproduzir o token original. Vazamento da tabela user_tokens não permite ativação retroativa.
  • One-shot. used_at é carimbado no consume; replay rejeitado com UnauthorizedException.
  • TTL-bounded. expires_at calculado a partir de AUTH_ACTIVATION_TTL_SECONDS / AUTH_PASSWORD_RESET_TTL_SECONDS. Tokens expirados rejeitados.
  • Anti-enumeração. POST /auth/password-reset/request retorna sempre HTTP 202 + corpo genérico, independente de o e-mail existir ou não. POST /auth/login levanta a mesma UnauthorizedException para email-errado vs senha-errada.
  • Password floor aplicado duas vezes. SignupSchema valida no input; UserAuthService revalida antes do hash — defesa em profundidade caso alguém bypasse o schema.

Pegando o current_user da requisição

make_auth_router emite o JWT pair (login/activate devolvem access_token + refresh_token). Mas e depois? Quando o frontend manda Authorization: Bearer <access_token> nas suas próprias rotas, você precisa de uma dependency que decodifica o token e resolve o usuário.

Desde a v0.49.0, o próprio UserAuthService constrói essa dependency — current_user_dependency(). Ela:

  1. Procura o token em três lugares, na ordem header → cookie → query string (primeiro hit ganha): Authorization: Bearer <jwt> via HTTPBearer, depois o cookie (cookie_name) e por último o parâmetro de query (query_param).
  2. Decodifica e verifica o JWT com o mesmo JWTUtils que o service usou pra assinar — sem segundo segredo pra manter sincronizado.
  3. Pega o sub (id do usuário) do payload, abre uma sessão a partir do db= e devolve o UserModel persistido.

1. Declare a dependency uma vez

O service já tem user_model, JWTUtils e a sessão — então não precisa escrever load_user à mão. Junte as duas variantes no src/api/dependencies/auth.py:

# src/api/dependencies/auth.py
from src.api.app import auth_service

get_current_user = auth_service.current_user_dependency()
get_current_user_or_none = auth_service.current_user_dependency(soft=True)

Requer db= no UserAuthService

current_user_dependency precisa do db= (o AsyncDatabaseManager do Setup mínimo) — sem ele, levanta RuntimeError. Como reusa o self.jwt interno, o token é validado com o mesmo segredo que assinou — o footgun de JWT_SECRET divergente some.

O usuário é carregado na sessão do request (db.session_dependency por default), a mesma que os seus repositórios usam. Ou seja: a instância vem attached e você pode mutar/refresh sem tomar InvalidRequestError: Instance is not persistent within this Session. Se os seus repositórios dependem de outro callable de sessão (um get_session local, por exemplo), passe esse mesmo callable em session_dependency= — o FastAPI cacheia sub-dependency por callable, então um wrapper diferente abre uma segunda sessão e volta a destacar o usuário.

A mesma garantia vale para as rotas autenticadas do próprio make_auth_router (/auth/password-change, /auth/mfa/*): elas carregam o usuário na sessão do request desde a 0.171.1. Antes disso o router abria uma sessão privada e devolvia a instância já detached, então a escrita era descartada em silêncio e o refresh seguinte estourava — /auth/password-change respondia 500 mantendo a senha antiga.

Não é só o header: cookie e query string também valem

A dependency tenta header → cookie → query string e para no primeiro hit, então a mesma linha acima serve cliente bearer e cliente cookie.

  • Cookie — automático. Com AUTH_TOKEN_DELIVERY em "cookie" ou "both", o cookie_name é auto-derivado de AUTH_ACCESS_COOKIE_NAME, o mesmo cookie que o /auth/login bundled setou. Zero wiring extra: rota guardada por essa dependency já aceita o cookie. Em delivery bearer-only fica None (só header). Passe cookie_name="..." pra forçar um nome.
  • Query string — opt-in. Nunca é auto-derivada. Serve pra cliente que não consegue mandar header nem cookie — caso clássico é o EventSource do browser (SSE) cross-origin:
# src/api/dependencies/auth.py
from src.api.app import auth_service

get_current_user = auth_service.current_user_dependency(
    cookie_name="access_token",
    query_param="access_token",
)

Detalhe do fluxo SSE na receita SSE ».

Token na URL vaza

Query string entra em log de acesso, histórico do browser e header Referer. Habilite query_param só sobre TLS, só com access token de vida curta (nunca refresh token), e limpe o valor do formato de log. Se o cliente compartilha a origem da API, prefira cookie de sessão (withCredentials).

Sem UserAuthService? Monte a dependency na mão

Se o seu serviço não usa o flow bundled, a primitiva make_jwt_user_dependency aceita qualquer JWTUtils + um user_loader async de um argumento:

from uuid import UUID

from tempest_fastapi_sdk import JWTUtils, make_jwt_user_dependency

from src.api.app import db
from src.core.settings import settings
from src.db.models import UserModel
from src.db.repositories import UserRepository

tokens: JWTUtils = JWTUtils(
    secret=settings.JWT_SECRET,
    algorithm=settings.JWT_ALGORITHM,
)


async def load_user(subject: str) -> UserModel:
    """Resolve o subject do JWT (uma string UUID) para o usuário persistido."""
    async with db.get_session_context() as session:
        repo: UserRepository = UserRepository(session)
        return await repo.get_by_id(UUID(subject))


get_current_user = make_jwt_user_dependency(tokens, load_user)
get_current_user_or_none = make_jwt_user_dependency(tokens, load_user, soft=True)

Atenção: aqui o tokens precisa usar o mesmo JWT_SECRET / JWT_ALGORITHM do login, senão todo token válido é rejeitado.

2. Injete na rota com Depends

# src/api/routers/users.py
from fastapi import APIRouter, Depends

from src.api.dependencies.auth import get_current_user
from src.db.models import UserModel
from src.schemas import UserResponseSchema

router: APIRouter = APIRouter(prefix="/users", tags=["users"])


@router.get("/profile")
async def profile(current: UserModel = Depends(get_current_user)) -> UserResponseSchema:
    """Devolve o usuário dono do bearer token da requisição."""
    return UserResponseSchema.model_validate(current)

current é o UserModel que o service resolveu — tipado, persistido, pronto pra uso. Token ausente ou inválido → 401 UnauthorizedException antes do corpo da rota rodar.

Pro /me puro, não escreva nada

O router empacotado já monta GET /auth/me desde a v0.198.0, com AuthUserSchema (as colunas que BaseUserModel garante) como resposta. Só escreva o seu quando a rota fizer algo além de devolver a conta — agregar contadores, juntar o perfil de outra tabela. Se for só uma coluna extra na mesma tabela, subclasse o schema em vez de reescrever a rota:

from tempest_fastapi_sdk import AuthUserSchema, make_auth_router


class UserResponseSchema(AuthUserSchema):
    name: str | None = None


app.include_router(
    make_auth_router(
        auth_service,
        session_factory=get_db,
        me_response_model=UserResponseSchema,
    )
)

3. Auth opcional — soft=True

Para rotas que funcionam logado e anônimo (ex.: feed público que personaliza se houver login), use a variante soft — ela devolve None em vez de levantar:

from fastapi import APIRouter, Depends

from src.api.dependencies.auth import get_current_user_or_none
from src.db.models import UserModel
from src.schemas import PostResponseSchema
from src.services import FeedService

feed_service = FeedService()

router = APIRouter()


@router.get("/feed")
async def feed(
    current: UserModel | None = Depends(get_current_user_or_none),
) -> list[PostResponseSchema]:
    """Feed público; personaliza o ranking quando há usuário logado."""
    if current is None:
        return await feed_service.public()
    return await feed_service.personalized(current.id)

Role e permission são o próximo passo

Quando a rota precisa de papel (admin) ou permissão (users:write) e não só "estar logado", troque por make_role_dependency / make_permission_dependency. Veja a receita HTTP » — mesma JWTUtils, mesmo padrão de Depends.

4. Guards imperativos — checagem dentro do service / controller

As deps acima gateiam a rota (antes do handler rodar). Mas e quando você já tem o user em mãos, mais fundo na pilha (service, controller), e quer só assertar uma condição antes de continuar? Desde a v0.50.0 o SDK traz três guards prontos — sem reescrever if user is None: raise ... em todo serviço:

from tempest_fastapi_sdk import (
    require_active,
    require_admin,
    require_authenticated,
)
Guard Levanta se Status HTTP
require_authenticated(user) user is None 401 UnauthorizedException
require_active(user) None, ou not user.is_active 401 / 403 ForbiddenException
require_admin(user) None, ou not user.is_admin 401 / 403 ForbiddenException

O detalhe que importa: cada um devolve o user já estreitado — sem None e com o tipo concreto preservado — então o resto da função para de ver | None:

from tempest_fastapi_sdk import require_admin

from src.db.models import UserModel


class ReportService:
    async def delete_all(self, current: UserModel | None) -> None:
        """Só admin apaga relatórios."""
        admin: UserModel = require_admin(current)  # 401/403, ou devolve tipado
        await self.repository.purge(by=admin.id)   # `admin` não é mais `| None`

Combina direto com o current_user_dependency(soft=True): a rota passa UserModel | None, e o guard decide no service.

Já tem o auth_service? Use os mirrors estáticos

Os mesmos guards existem como staticmethods em UserAuthServiceauth_service.require_admin(current) — pra quando você já injeta o service e não quer um import extra. Mesma semântica, mesma exceção.


Recap

  • O fluxo bundled cobre signup, ativação, login, reset de senha e troca de e-mail — e o UserTokenModel concreto é a única tabela que o seu serviço precisa ship.
  • Recuperação de senha e recuperação de e-mail são fluxos diferentes: um é para quem esqueceu a senha, o outro para quem perdeu o acesso à caixa.
  • Os cinco modos de operação existem porque "quem renderiza a página" muda por projeto: API pura, backend renderizando, SPA, ou combinação.
  • AUTH_TOKEN_DELIVERY decide se o par de JWT sai no corpo, no cookie ou nos dois — e cookie cross-site exige SameSite=none mais Secure.
  • Template e idioma são sobrescrevíveis, mas o default funciona sem você criar arquivo nenhum.
  • Prefixo do router e URL do template têm de concordar: montar em /api com template apontando para a raiz dá 404 no clique do usuário, não no deploy.

Próximos passos

  • Idempotência » — proteja POST /auth/signup de retentativas que duplicariam linha.
  • Storage MinIO/S3 » — anexar avatar / foto de perfil já no signup.
  • Logging »request_id já propaga automaticamente em cada log emitido durante o flow.
  • Métricas »PrometheusMiddleware conta /auth/* separadamente sem config extra.