Skip to content

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ígitosTipoEntidade
11CPFPessoa física (usuário, responsável)
12INEPEscola (código do censo)
14CNPJEscola (pessoa jurídica)

Processamento no frontend:

  1. Limpar máscara (remover ., -, /)
  2. Contar dígitos
  3. Classificar tipo automaticamente

Helpers disponíveis em src/lib/auth.ts:

  • normalizarCodigo() — remove formatação
  • identificarTipoCodigo() — retorna 'cpf' | 'cnpj' | 'inep'
  • validarFormatoCodigo() — valida formato básico
  • formatarCodigo() — 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)

PropriedadeValor
SecretOLP_JWT_SECRET (sincronizado com Supabase API JWT Secret)
AlgoritmoHS256
Expiração8 horas
Cookieolp_auth

Claims:

json
{
  "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" e aud: "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): O escola_id no JWT vem do usuario_papeis correspondente ao principal_role selecionado automaticamente.
  • Troca de perfil (select-role): O frontend envia { papel, escola_id }. O backend valida que o par existe em usuario_papeis e re-assina o JWT com o escola_id correto.
  • Papéis globais (administrador, especialista): escola_id = null no 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)

PropriedadeValor
SecretOLP_JWT_SECRET (unificado — ver nota abaixo)
AlgoritmoHS256
Expiração2 horas
Cookieolp_mural

Claims (Aluno):

json
{
  "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):

json
{
  "sub": "uuid-do-responsavel",
  "portal_type": "responsavel",
  "nome_completo": "Nome do Responsável",
  "escola_id": "uuid",
  "role": "authenticated",
  "aud": "authenticated"
}

Configuração

olp_auth=<token>; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=28800  (8 horas)
AtributoValorMotivo
HttpOnlySimImpede acesso via JavaScript (proteção XSS)
SecureSimSó transmite via HTTPS
SameSiteNonePermite cross-site (necessário para Edge Functions)
Max-Age288008 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:

  1. Lê o header Cookie do request
  2. Extrai o valor de olp_auth
  3. Verifica assinatura com OLP_JWT_SECRET
  4. Valida expiração
  5. 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

  1. Token é decodificado e validado (assinatura + expiração)
  2. Se o payload contém jti, verifica se existe na token_blacklist via .maybeSingle()
  3. Se encontrado: rejeita com 401 "Token revogado" + alerta push via ntfy.sh
  4. 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

sql
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_expira para limpeza eficiente
  • Constraint UNIQUE em jti cria índice B-tree automaticamente

Limpeza Automática

O maintenance-cron executa diariamente:

sql
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

typescript
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, OPTIONS

Regras:

  1. SEMPRE usar getCorsHeaders(req) e handleCorsPrelight(req)
  2. NUNCA usar '*' como origin com credentials
  3. 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.digital

Headers 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_Paulo

Esses 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çãoAuthO que testa
public_smokePostgREST público funciona
admin_whoamiolp_authToken do sistema é válido
admin_rls_smokeolp_authToken é aceito pelo PostgREST + RLS funciona
portal_whoamiolp_muralToken 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:

  1. Access token (15min) + Refresh token (7d) em cookie HttpOnly separado
  2. Endpoint /refresh-token que emite novo access token
  3. Refresh token rotation: cada uso do refresh token emite um novo (o anterior é revogado)
  4. 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:

  1. Cookies separados (olp_auth vs olp_mural) — extractToken() só lê olp_auth
  2. Guard explícito em extractAuthenticatedUser() que bloqueia tokens com portal_type ou escopo: '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

ArquivoResponsabilidade
supabase/functions/send-otp/index.tsEnvio de OTP via WhatsApp (fallback SMS)
supabase/functions/verify-otp/index.tsValidação de OTP + geração de JWT
supabase/functions/me/index.tsRetorna dados do usuário logado
supabase/functions/logout/index.tsLimpa cookie
supabase/functions/_shared/auth-helpers.tsextractAuthenticatedUser()
supabase/functions/_shared/jwt.tscreateAuthToken(), verifyAuthToken()
supabase/functions/_shared/jwt-portal.tsJWT do portal
supabase/functions/_shared/cors-helpers.tsCORS dinâmico
src/lib/auth.tsHelpers frontend (normalizar, formatar, etc.)
src/contexts/auth-context.tsxContext React de sessão
workers/docs-auth/src/index.tsWorker 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:

typescript
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

  1. Worker → redirect_error param: O Worker sinaliza a razão da falha (ex: no_cookie, jwt_expired). O frontend detecta esse parâmetro e aborta o redirect imediatamente.

  2. Frontend → Cooldown temporal: Se o mesmo hostname foi tentado nos últimos 10 segundos, o redirect é abortado.

  3. 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:

  1. Assets estáticos (.css, .js, imagens) → proxy direto sem auth
  2. HTML/páginas → valida cookie olp_auth (JWT HS256 via OLP_JWT_SECRET)
  3. Requer principal_role === 'administrador'
  4. Produção (docs.olp.digital): falha de auth → redirect para olp.digital/?redirect=...&redirect_error=<reason>
  5. Preview (*.workers.dev, *.lovable.app, *.lovableproject.com): falha de auth → 401 diagnóstico (HTML ou JSON, sem redirect)
  6. Role insuficiente → redirect para olp.digital (sem redirect param) — apenas em produção
  7. Emite logs JSON estruturados com request_id (visíveis via wrangler tail)
  8. 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

AmbienteCookie disponívelComportamento do Worker
docs.olp.digital✅ (via .olp.digital)Redirect 302 em falha
*.workers.dev401 diagnóstico HTML
*.lovable.app401 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:

  1. Usar modo debug: https://docs.olp.digital/?__debug_auth=1
  2. Verificar headers: X-Docs-Auth-Reason, X-Docs-Auth-Has-Cookie no DevTools
  3. wrangler tail olp-docs-auth — ver campo reason + request_id nos logs
  4. no_cookie → Gateway não está reescrevendo Domain=.olp.digital
  5. jwt_signature_invalidOLP_JWT_SECRET diverge entre Worker e Supabase
  6. jwt_expired → sessão expirou
  7. Verificar DOCS_PAGES_HOSTwrangler pages project list e 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:

  1. wrangler tail olp-docs-auth — ver campo reason nos logs
  2. no_cookie → Gateway não está reescrevendo Domain=.olp.digital
  3. jwt_signature_invalidOLP_JWT_SECRET diverge entre Worker e Supabase
  4. jwt_expired → sessão expirou
  5. Verificar DOCS_PAGES_HOSTwrangler pages project list e 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).