Autenticação — Plataforma OLP
Visão Geral
A OLP usa autenticação sem senhas, baseada em OTP (código de uso único) enviado via WhatsApp (Wasender). O sistema gera JWTs customizados assinados com HS256 e transportados via cookies HttpOnly.
Login Unificado
Campo Único "Código"
O frontend aceita um único campo que identifica o tipo pelo número de dígitos:
| Dígitos | Tipo | Entidade |
|---|---|---|
| 11 | CPF | Pessoa física (usuário, responsável) |
| 12 | INEP | Escola (código do censo) |
| 14 | CNPJ | Escola (pessoa jurídica) |
Processamento no frontend:
- Limpar máscara (remover
.,-,/) - Contar dígitos
- Classificar tipo automaticamente
Helpers disponíveis em src/lib/auth.ts:
normalizarCodigo()— remove formataçãoidentificarTipoCodigo()— retorna'cpf' | 'cnpj' | 'inep'validarFormatoCodigo()— valida formato básicoformatarCodigo()— formata para exibição
Fluxo OTP Completo
┌─────────────────────────────────────────────────────────────────┐
│ FLUXO DE AUTENTICAÇÃO OTP │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Usuário digita código (CPF/INEP/CNPJ) │
│ │ │
│ ▼ │
│ 2. Frontend → POST /functions/v1/send-otp │
│ Body: { codigo: "12345678901" } │
│ │ │
│ ▼ │
│ 3. Backend: │
│ a. Normaliza código │
│ b. Busca usuário em `usuarios` (ativo = true) │
│ c. Verifica rate-limit (3 OTPs/15min por user, 10/h por IP) │
│ d. Gera OTP de 6 dígitos │
│ e. Hash SHA-256 do OTP │
│ f. Salva em `login_otps` (expira em 5 min) │
│ g. Envia OTP via WhatsApp (Wasender) │
│ │ │
│ ▼ │
│ 4. Usuário recebe mensagem WhatsApp e digita o código de 6 dígitos │
│ │ │
│ ▼ │
│ 5. Frontend → POST /functions/v1/verify-otp │
│ Body: { codigo: "12345678901", otp: "123456" } │
│ │ │
│ ▼ │
│ 6. Backend: │
│ a. Busca OTP válido (não expirado, não usado) │
│ b. Compara hash SHA-256 │
│ c. Se inválido: incrementa tentativas (max 3 por OTP) │
│ d. Se válido: marca como usado │
│ e. Busca papéis do usuário (usuario_papeis + papeis) │
│ f. Gera JWT customizado (HS256, 8h) │
│ g. Retorna cookie HttpOnly `olp_auth` │
│ h. Loga `login.success` em `logs_transacoes` │
│ │ │
│ ▼ │
│ 7. Frontend recebe cookie automaticamente │
│ Requests subsequentes incluem cookie via credentials:include │
│ │
└─────────────────────────────────────────────────────────────────┘JWT — Dois Contextos Separados
Sistema Admin (olp_auth)
| Propriedade | Valor |
|---|---|
| Secret | OLP_JWT_SECRET (sincronizado com Supabase API JWT Secret) |
| Algoritmo | HS256 |
| Expiração | 8 horas |
| Cookie | olp_auth |
Claims:
{
"sub": "uuid-do-usuario",
"auth_user_id": "uuid-opcional",
"nome_completo": "Nome do Usuário",
"principal_role": "escola",
"escola_id": "uuid-da-escola-ativa",
"usuario_id": "uuid-do-usuario",
"roles": [
{ "nome": "escola", "escola_id": "uuid-escola-A" },
{ "nome": "coordenador", "escola_id": "uuid-escola-B" }
],
"role": "authenticated",
"aud": "authenticated",
"iat": 1700000000,
"exp": 1700043200
}Obrigatório:
role: "authenticated"eaud: "authenticated"para que o PostgREST aplique RLS.
Multi-Escola: escola_id no JWT
A claim escola_id é derivada dinamicamente da tabela usuario_papeis, não mais do campo legado usuarios.escola_id.
- Login (
verify-otp): Oescola_idno JWT vem dousuario_papeiscorrespondente aoprincipal_roleselecionado automaticamente. - Troca de perfil (
select-role): O frontend envia{ papel, escola_id }. O backend valida que o par existe emusuario_papeise re-assina o JWT com oescola_idcorreto. - Papéis globais (administrador, especialista):
escola_id = nullno JWT. - Campo legado
usuarios.escola_id: Atualizado por write-through ao trocar perfil, mas não é a fonte da verdade. A SSOT éusuario_papeis.escola_id.
Isso garante que as ~60 RLS policies que filtram por auth.jwt() ->> 'escola_id' funcionem corretamente em qualquer contexto institucional sem alteração.
Mural Olímpico — Aluno/Responsável (olp_mural)
| Propriedade | Valor |
|---|---|
| Secret | OLP_JWT_SECRET (unificado — ver nota abaixo) |
| Algoritmo | HS256 |
| Expiração | 2 horas |
| Cookie | olp_mural |
Claims (Aluno):
{
"sub": "uuid-do-aluno",
"portal_type": "aluno",
"nome_completo": "Nome do Aluno",
"escola_id": "uuid",
"serie_id": "uuid",
"turma_id": "uuid",
"matricula": "123456",
"role": "authenticated",
"aud": "authenticated"
}Claims (Responsável):
{
"sub": "uuid-do-responsavel",
"portal_type": "responsavel",
"nome_completo": "Nome do Responsável",
"escola_id": "uuid",
"role": "authenticated",
"aud": "authenticated"
}Cookie HttpOnly
Configuração
olp_auth=<token>; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=28800 (8 horas)| Atributo | Valor | Motivo |
|---|---|---|
HttpOnly | Sim | Impede acesso via JavaScript (proteção XSS) |
Secure | Sim | Só transmite via HTTPS |
SameSite | None | Permite cross-site (necessário para Edge Functions) |
Max-Age | 28800 | 8 horas (sistema) / 7200 = 2 horas (portal) |
Path | / | Disponível em todas as rotas |
Extração no Backend
O helper extractAuthenticatedUser(req) em _shared/auth-helpers.ts:
- Lê o header
Cookiedo request - Extrai o valor de
olp_auth - Verifica assinatura com
OLP_JWT_SECRET - Valida expiração
- Retorna o objeto
AuthenticatedUser
Revogação de Tokens (jti + Blacklist)
Mecanismo
Cada JWT emitido contém um campo jti (JWT ID) único gerado via crypto.randomUUID(). Ao fazer logout, o jti é inserido na tabela token_blacklist com a data de expiração do token.
Fluxo de Verificação
- Token é decodificado e validado (assinatura + expiração)
- Se o payload contém
jti, verifica se existe natoken_blacklistvia.maybeSingle() - Se encontrado: rejeita com 401 "Token revogado" + alerta push via ntfy.sh
- Se não encontrado: continua normalmente
Retrocompatibilidade
Tokens emitidos antes da implementação (sem jti) continuam válidos — a verificação de blacklist só ocorre se jti existir no payload.
Tabela token_blacklist
CREATE TABLE public.token_blacklist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti TEXT NOT NULL UNIQUE,
usuario_id UUID NOT NULL,
motivo TEXT NOT NULL DEFAULT 'logout',
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
expira_em TIMESTAMPTZ NOT NULL
);- RLS ativo sem policies: acesso exclusivo via
service_role - Índice
idx_token_blacklist_expirapara limpeza eficiente - Constraint
UNIQUEemjticria índice B-tree automaticamente
Limpeza Automática
O maintenance-cron executa diariamente:
DELETE FROM token_blacklist WHERE expira_em < now()Alerta de Segurança
Quando um token revogado é reutilizado, o sistema envia alerta push via ntfy.sh com prioridade high, indicando possível vazamento de token.
CORS — Regras Obrigatórias
Problema
Cookies HttpOnly não funcionam com Access-Control-Allow-Origin: '*' quando credentials: 'include' é usado.
Solução
Arquivo: supabase/functions/_shared/cors-helpers.ts
const ALLOWED_ORIGINS = [
'http://localhost:5173',
'http://localhost:8080',
'https://b4188062-97f3-4ab8-aba4-214ee32684a0.lovableproject.com',
'https://id-preview--b4188062-97f3-4ab8-aba4-214ee32684a0.lovable.app',
'https://olp.digital',
];Headers retornados:
Access-Control-Allow-Origin: <origem exata do request>
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: authorization, x-client-info, apikey, content-type, cookie, accept
Access-Control-Allow-Methods: POST, GET, OPTIONSRegras:
- SEMPRE usar
getCorsHeaders(req)ehandleCorsPrelight(req) - NUNCA usar
'*'como origin com credentials - Incluir corsHeaders em TODAS as respostas (sucesso E erro)
Cloudflare Worker (Produção)
Em produção (olp.digital), um Cloudflare Worker atua como proxy:
Reescrita de Cookies
# Antes (Supabase retorna)
Set-Cookie: olp_auth=xxx; HttpOnly; Secure; SameSite=None; Path=/
# Depois (Worker reescreve)
Set-Cookie: olp_auth=xxx; HttpOnly; Secure; SameSite=None; Path=/; Domain=.olp.digitalHeaders de Geolocalização
O Worker injeta automaticamente:
X-Geo-City: São Paulo
X-Geo-Region: SP
X-Geo-Country: BR
X-Geo-Timezone: America/Sao_PauloEsses headers são lidos pelo logging-helper.ts para geolocalização precisa nos logs.
Ferramentas de Diagnóstico
A Edge Function auth-diagnostics oferece 4 ações para validar o sistema:
| Ação | Auth | O que testa |
|---|---|---|
public_smoke | ❌ | PostgREST público funciona |
admin_whoami | olp_auth | Token do sistema é válido |
admin_rls_smoke | olp_auth | Token é aceito pelo PostgREST + RLS funciona |
portal_whoami | olp_mural | Token do mural é válido |
Ver detalhes completos em: AUTH_DIAGNOSTICS.md
Melhorias Futuras (Roadmap)
Rotação de JWT (Refresh Token Rotation)
Status: Planejado — não prioritário no momento.
A arquitetura atual usa tokens JWT com 8h de vida e revogação server-side via token_blacklist (por jti). Isso é suficiente para o perfil de risco atual.
Quando implementar:
- Quando o número de usuários simultâneos justificar tokens de vida mais curta (< 1h)
- Quando houver requisito regulatório de rotação periódica
- Quando a plataforma expor APIs públicas que exijam tokens de acesso granulares
Implementação sugerida:
- Access token (15min) + Refresh token (7d) em cookie HttpOnly separado
- Endpoint
/refresh-tokenque emite novo access token - Refresh token rotation: cada uso do refresh token emite um novo (o anterior é revogado)
- Detecção de reuso de refresh token revogado → revogar toda a família de tokens
Justificativa do adiamento: O custo de implementação (novo endpoint, migração de clientes, gestão de família de tokens) supera o benefício marginal dado que a revogação server-side via token_blacklist já cobre cenários de comprometimento.
Validação de aud (Audience) no JWT
Status: Parcialmente implementado.
A segregação portal/sistema é garantida por:
- Cookies separados (
olp_authvsolp_mural) —extractToken()só lêolp_auth - Guard explícito em
extractAuthenticatedUser()que bloqueia tokens comportal_typeouescopo: 'portal_readonly'
Melhoria futura: adicionar claim aud nos tokens e validar via jose.jwtVerify(token, key, { audience: 'olp:sistema' }) para rejeição automática pela biblioteca, sem depender de código customizado.
Validação de Input com Zod (Edge Functions)
Status: Planejado — implementação faseada.
Ver seção 1.9 em docs/development/STATE_OF_THE_ART.md.
Arquivos Relacionados
| Arquivo | Responsabilidade |
|---|---|
supabase/functions/send-otp/index.ts | Envio de OTP via WhatsApp (fallback SMS) |
supabase/functions/verify-otp/index.ts | Validação de OTP + geração de JWT |
supabase/functions/me/index.ts | Retorna dados do usuário logado |
supabase/functions/logout/index.ts | Limpa cookie |
supabase/functions/_shared/auth-helpers.ts | extractAuthenticatedUser() |
supabase/functions/_shared/jwt.ts | createAuthToken(), verifyAuthToken() |
supabase/functions/_shared/jwt-portal.ts | JWT do portal |
supabase/functions/_shared/cors-helpers.ts | CORS dinâmico |
src/lib/auth.ts | Helpers frontend (normalizar, formatar, etc.) |
src/contexts/auth-context.tsx | Context React de sessão |
workers/docs-auth/src/index.ts | Worker auth para docs.olp.digital |
Redirect Cross-Domínio Pós-Autenticação
Fluxo
Quando um usuário não autenticado acessa docs.olp.digital:
Browser → docs.olp.digital (Worker olp-docs-auth)
→ Sem cookie olp_auth válido
→ 302 Redirect → https://olp.digital/?redirect=https://docs.olp.digital&redirect_error=no_cookie
→ Usuário faz login normalmente
→ authPhase → 'authenticated'
→ auth-context.tsx lê ?redirect= e ?redirect_error= da URL
→ Se redirect_error presente → aborta redirect (evita loop)
→ Se sem erro → valida hostname contra whitelist
→ window.location.href → https://docs.olp.digital
→ Worker valida cookie olp_auth (agora presente, Domain=.olp.digital)
→ Proxy → olp-anp290q1f9.pages.dev (conteúdo servido)Segurança: Anti Open-Redirect
O frontend mantém uma whitelist de domínios confiáveis para evitar ataques de open-redirect:
const TRUSTED_REDIRECT_DOMAINS = ['docs.olp.digital', 'staging.olp.digital'];Apenas URLs com https:// e hostname presente na whitelist são redirecionadas. Tentativas com domínios externos (ex: ?redirect=https://evil.com) são silenciosamente ignoradas.
Anti-Loop: 3 Camadas de Proteção
Worker →
redirect_errorparam: O Worker sinaliza a razão da falha (ex:no_cookie,jwt_expired). O frontend detecta esse parâmetro e aborta o redirect imediatamente.Frontend → Cooldown temporal: Se o mesmo hostname foi tentado nos últimos 10 segundos, o redirect é abortado.
Frontend → Contador de tentativas: Máximo de 2 tentativas por hostname por sessão. Após isso, o redirect é permanentemente abortado para aquela sessão.
Worker docs-auth
O Worker olp-docs-auth (em workers/docs-auth/) atua como proxy reverso para o Cloudflare Pages:
- Assets estáticos (
.css,.js, imagens) → proxy direto sem auth - HTML/páginas → valida cookie
olp_auth(JWT HS256 viaOLP_JWT_SECRET) - Requer
principal_role === 'administrador' - Produção (
docs.olp.digital): falha de auth → redirect paraolp.digital/?redirect=...&redirect_error=<reason> - Preview (
*.workers.dev,*.lovable.app,*.lovableproject.com): falha de auth → 401 diagnóstico (HTML ou JSON, sem redirect) - Role insuficiente → redirect para
olp.digital(sem redirect param) — apenas em produção - Emite logs JSON estruturados com
request_id(visíveis viawrangler tail) - Headers
X-Docs-Auth-*em todas as respostas para diagnóstico
Modo Debug
Para diagnóstico JSON sem redirect: ?__debug_auth=1 ou Accept: application/json.
Host interno do Pages
O CI (deploy-docs.yml) faz deploy para olp-anp290q1f9.pages.dev. Este host é intencionalmente ofuscado — não é olp-docs.pages.dev.
O valor está versionado em workers/docs-auth/wrangler.toml como variável não-secreta DOCS_PAGES_HOST.
Ambientes e Cookies
| Ambiente | Cookie disponível | Comportamento do Worker |
|---|---|---|
docs.olp.digital | ✅ (via .olp.digital) | Redirect 302 em falha |
*.workers.dev | ❌ | 401 diagnóstico HTML |
*.lovable.app | ❌ | 401 diagnóstico HTML |
IMPORTANTE: Ambientes de preview não são válidos para testar acesso autenticado a docs.olp.digital.
Troubleshooting: Loop de Redirect
Se docs.olp.digital entra em loop infinito:
- Usar modo debug:
https://docs.olp.digital/?__debug_auth=1 - Verificar headers:
X-Docs-Auth-Reason,X-Docs-Auth-Has-Cookieno DevTools wrangler tail olp-docs-auth— ver camporeason+request_idnos logsno_cookie→ Gateway não está reescrevendoDomain=.olp.digitaljwt_signature_invalid→OLP_JWT_SECRETdiverge entre Worker e Supabasejwt_expired→ sessão expirou- Verificar DOCS_PAGES_HOST →
wrangler pages project liste comparar
Nota sobre .pages.dev
O domínio .pages.dev do Cloudflare Pages é sempre público por design. A proteção primária está no Worker em docs.olp.digital. Para bloqueio completo do .pages.dev, usar Cloudflare Access (Zero Trust).
Host interno do Pages
O CI (deploy-docs.yml) faz deploy para olp-anp290q1f9.pages.dev. Este host é intencionalmente ofuscado — não é olp-docs.pages.dev.
O valor está versionado em workers/docs-auth/wrangler.toml como variável não-secreta DOCS_PAGES_HOST.
Troubleshooting: Loop de Redirect
Se docs.olp.digital entra em loop infinito:
wrangler tail olp-docs-auth— ver camporeasonnos logsno_cookie→ Gateway não está reescrevendoDomain=.olp.digitaljwt_signature_invalid→OLP_JWT_SECRETdiverge entre Worker e Supabasejwt_expired→ sessão expirou- Verificar DOCS_PAGES_HOST →
wrangler pages project liste comparar
Nota sobre .pages.dev
O domínio .pages.dev do Cloudflare Pages é sempre público por design. A proteção primária está no Worker em docs.olp.digital. Para bloqueio completo do .pages.dev, usar Cloudflare Access (Zero Trust).