Pular para o conteúdo principal

Fluxo de Autenticação

Visão geral

Desde a spec user-data-out-of-ssr (2026-07-24, commit 6b1f32a90 no base), o documento SSR é auth-agnóstico: nenhum dado do usuário logado atravessa o server-render. O loader do _layout não retorna auth. Toda a hidratação de auth é client-side.

┌────────────────────────────────────────────────────────────┐
│ Server (loader / API routes) │
│ `_layout` loader: brand + clientEnv (ZERO dado de user) │
│ `/api/auth/*`: login/register/logout/profile │
│ Token lido só aqui, do cookie HttpOnly │
├────────────────────────────────────────────────────────────┤
│ Client (browser) │
│ useAuthInit() → /api/auth/profile → useAccountsStore │
│ useAuthGate() → o único gate de "está logado?" pra UI │
└────────────────────────────────────────────────────────────┘

Por que isso importa: o HTML público passa a ser compartilhável entre usuários, o que é o que torna o cache de documento na borda (e no Service Worker) viável. Ver Cache architecture e Service Worker.

:::danger Nunca leia auth do loader

// ❌ O campo não existe mais. `layoutData.auth` é sempre undefined.
const layoutData = useRouteLoaderData("routes/_layout");
const isLoggedIn = Boolean(layoutData?.auth?.user?.id);

Existe um teste leak-canary que falha se alguém re-adicionar um campo derivado do usuário ao loader: app/routes/__tests__/no-user-data-in-ssr.test.ts. :::

useAuthGate() — o gate canônico

app/hooks/useAuthGate.ts é o único gate de auth pra UI. Todo componente que condiciona visibilidade ou comportamento a "estar logado" usa este hook.

import { useAuthGate } from "~/hooks/useAuthGate";

function MyComponent() {
const isLoggedIn = useAuthGate();
if (!isLoggedIn) return null;
// …
}

Três fases, nesta ordem:

  1. SSR e primeiro render client — retorna false. Sempre pinta a casca de convidado. O pre-paint guard segura o flash (abaixo).
  2. Pós-mount, pré-hidratação da store — o cookie não-HttpOnly is_authenticated é o sinal otimista.
  3. Pós-authHydrated — a store reativa assume; || cookieAuth cobre a janela do fetch de perfil.

O hook re-lê o cookie quando storeAuthenticated/authHydrated mudam e escuta AUTH_FLAG_CLEARED_EVENT (~/utils/cookie.client), então login/logout via modal refletem na hora — sem navegação, sem revalidator.revalidate().

Pre-paint guard data-auth-flag

app/root.tsx injeta um script síncrono que, antes do primeiro paint, lê o cookie is_authenticated e marca data-auth-flag no <html>. O CSS esconde os CTAs de convidado e mostra o placeholder do cluster logado.

Sem isso, o usuário logado que recebe o HTML anônimo do cache de borda veria "Entrar / Cadastrar" por toda a janela de hidratação — parece que deslogou. Pós-hidratação o DefaultLayout reconcilia o atributo com a verdade da store (cookie stale / 401 → atributo sai, CTAs voltam).

Outros valores reativos

A mesma regra vale pra qualquer valor que muda em runtime sem revalidar o loader:

O queFonte reativa
isAuthenticated (gate de UI)useAuthGate()
user, userInfo, wallet, transações, coinsuseAccountsStore (@cactus-agents/accounts/react)
Gamification (missões, torneios, níveis)useGamificationStore
Favoritos (slugs)useGamesStore.favoriteSlugs
Estado do modal de authuseAuthModalStore (app/store/authModal.ts)

Hidratação — useAuthInit()

app/hooks/useAuthInit.ts hidrata a store 100% client-side:

<AuthInitializer /> monta (dentro do DefaultLayout)
├── useAuthInit()
│ ├── hasAuthFlagCookie()?
│ │ ├── não → setAuthHydrated() (anônimo, zero request)
│ │ └── sim → setAuthHydrated() já (evita flicker de loading)
│ │ ├── fast path: cookie `gm_id` já traz o smarticoHash
│ │ │ → gamificação inicializa ANTES do RTT do profile
│ │ └── fetch("/api/auth/profile")
│ ├── useAuthProfileSync()
│ ├── useValidationRuntimeSync()
│ ├── useHeaderNotificationsSync()
│ ├── useRewardsCountSync()
│ └── useFavoritesAuthSync()

<LazyWalletInitializer /> (app/components/user/WalletInitializer.tsx) é montado condicionalmente pelo DefaultLayout, dentro de um <Suspense>, só quando hydrated && isAuthenticated.

Token morto × falha transitória

fetchProfileFromApi() devolve um resultado discriminado de propósito:

type ProfileResult =
| { kind: "ok"; user: AuthUser; userInfo: AuthUserInfo; smarticoHash: string | null }
| { kind: "unauthorized" } // 401 ou 440 → token morto, limpa cookies
| { kind: "error" }; // 502 / rede / shape ruim → NÃO desloga

Colapsar tudo em null deslogaria o usuário num soluço de rede. Essa distinção é o ponto central do hook.

Memoização por TTL

A store carrega lastProfileFreshAt / lastWalletFreshAt (Date.now() da última população por fonte confiável) e usa isso pra pular round-trips redundantes ao BFF quando acabou de ser preenchida:

AçãoTTLConstante
refreshAuthProfile()10sPROFILE_FRESH_TTL_MS
refetchWallet()2sWALLET_FRESH_TTL_MS

Ambas aceitam { force: true } pra ignorar o memo. refetchWallet também faz dedup de in-flight, o que cobre o caso de um botão de reload manual disparar user:wallet-refresh e um listener responder ao mesmo evento.

useAuthProfileSync (no base) se apoia nesse memo — é por isso que montar vários consumidores de perfil não multiplica chamadas ao BFF.

Os três cookies

CookieHttpOnlyMax-AgePapel
jwt_tokensim30dO token. Lido só no server (getTokenFromRequest)
is_authenticatednão30dSinal otimista pro useAuthGate + pre-paint guard
gm_idnão24huserId + smarticoHash — fast path da gamificação

O TTL do gm_id é casado com o do hash do Smartico (24h: generateUserHash no core embute expiry = now + 24h e assina). Um Max-Age maior serviria um hash já expirado. Quando o cookie some, o client cai no /api/auth/profile, que gera um hash novo e reescreve o cookie.

Definições em app/utils/cookie.server.ts.

Login

Login e registro passam por API routes no server, pra o cookie HttpOnly ser setado server-side. O client nunca manipula o token.

A rota normaliza toda ramificação do BFF num envelope com code (FormErrorCode, de @cactus-agents/utils/forms) — não uma string type:

// POST /api/auth/login → app/routes/api/auth/login.ts
// sucesso
{ ok: true, user, userInfo, trackingHeaders? }

// falhas
{ ok: false, code: FormErrorCode.TWO_FACTOR_REQUIRED, two_factor_type?: "email" | "sms" }
{ ok: false, code: FormErrorCode.ACCOUNT_CANCELLED }
{ ok: false, code: FormErrorCode.ATTEMPT_LIMIT, recovery_token: string | null }
{ ok: false, code: FormErrorCode.BAD_REQUEST, fieldErrors }
{ ok: false, code: FormErrorCode.SERVER_ERROR, detail? }

O mapeamento vem de mapBffLoginError. Trate sempre pelo code.

2FA está implementado

O passo de 2FA existe: app/components/auth/TwoFactorStep.tsx, orquestrado pelo LoginModal.tsx. O two_factor_type ("email" ou "sms") é encaminhado justamente pra o modal renderizar o passo certo.

Detalhes do caminho de login

  • Retry por lag de read-replica — depois do login, o fetch de /auth/user-profile é repetido uma vez após PROFILE_RETRY_DELAY_MS = 1000. Contorna a réplica de leitura do BFF ainda não ter o usuário.
  • primeAuthTokenCache(access_token, profile) — semeia um cache de token no server (curto) pra a primeira request autenticada não pagar o RTT.
  • Três cookies setadosjwt_token, is_authenticated, gm_id.

Registro

RegisterModal
├── Formulário dinâmico (campos conforme authConfig da marca)
├── POST /api/auth/register
│ └── Server: valida payload + resolveRegisterEndpoint(registerConfig, input.socialService)
│ + Set-Cookie (mesmos três cookies)
└── Envelope com `code` (mesmo contrato do login)

O endpoint do BFF não é hardcoded: resolveRegisterEndpoint (app/modules/register/flow.ts) resolve em runtime a partir do registerConfig da marca e do provider social. Não replique a lista de paths.

O fluxo social inicia em /api/auth/social/:provider, que redireciona pro provider no BFF e retorna ao modal com os parâmetros do callback.

Logout

Header → "Sair"
├── POST /api/auth/logout
│ └── Server: logout no BFF + makeDeleteAllAuthHeaders()
└── clearAuth() na store + useAuthLogoutCleanup()

makeDeleteAllAuthHeaders() (app/utils/cookie.server.ts) apaga 7 cookies: jwt_token, is_authenticated, gm_id, três variantes legadas __Host-jwt_token_{strict,lax,none} (setadas pelo BFF, cada uma com o seu SameSite) e bet7k_session.

clearAuth() — reset único, sem cross-store

clearAuth na useAccountsStore (core, packages/accounts/src/react/store.ts) zera auth e wallet num único set() — não há chamada cross-store:

clearAuth: () => {
set({
user: null, userInfo: null, isAuthenticated: false, lastProfileFreshAt: 0,
wallet: null, lastWalletFreshAt: 0,
transactions: [], transactionsRaw: null, transactionsMeta: null,
transactionsFilter: { page: 1 },
error: null,
activeCoinId: resolveDefaultCoinId(),
coinBalances: null,
});
}

useAuthLogoutCleanup()

app/hooks/useAuthLogoutCleanup.ts roda só na transição real true → false (um ref guarda contra o render inicial de visitante) e faz o resto da limpeza: fecha todo modal/painel auth-scoped, reseta as stores do base que carregam dado auth-scoped (authModal, gamification, kyc, layout, payments, userConfigCache, validationRuntime, validationSteps, walletModal — a lista viva são os imports no topo do arquivo), zera a identidade do Smartico (_smartico.logout() + os globais _smartico_user_*) e chama purgeAllClientCaches().

Exceção deliberada: quando o logout veio de sessão expirada (authModalReason.kind === "session_expired"), o modal de auth não é fechado — ele acabou de ser aberto pelo handler de 401 e fechá-lo aqui mataria o propósito.

Tratamento de 401 / 440

Não é loader-driven. O chokepoint único é handleUnauthorized() em app/utils/api-fetch.client.ts, instalado pelo interceptor de fetch (installFetchInterceptor, chamado em app/entry.client.tsx):

  • Trata 401 e 440 como token morto (440 = sessão encerrada server-side).
  • Idempotente: uma segunda chamada com isAuthenticated === false é no-op, então rajadas de 401 concorrentes não disparam limpezas múltiplas.
  • Classifica o erro (classifyApiError) e manda um beacon pra /api/logs/auth-logout (CF Observability), com uma referência #EP-EM.
  • Limpa o cookie HttpOnly via deleteJwtCookie(), que faz POST /api/auth/logout (keepalive: true, best-effort). Essa rota chama createAuthFromClient(client).logout().
  • Captura o último identificador de login antes do clearAuth, pra pré-preencher o modal — o usuário só redigita a senha.
  • Seta authModalReason.kind = "session_expired", o que faz o app/components/auth/SessionExpiredBanner.tsx renderizar.

:::warning O audit log do BFF NÃO distingue logout forçado de voluntário hoje Existe a intenção de distinguir — POST /auth/logout-auto no BFF — mas o caminho está incompleto em três camadas, então nada disso está ativo:

  1. Core, root: AuthService.logoutAuto() é real e testado (packages/accounts/src/auth-service.ts). Alcançável só pelo AuthService do root.
  2. Core, camada React: existe um adapter defaultLogoutAuto() (packages/accounts/src/react/auth-http.ts) apontando pra /api/auth/logout-auto, mas ele não é exportado do barrel /react e não tem nenhum call site. Código morto.
  3. Core, seam de config: AccountsConfig (react/config.ts) tem logout? mas não tem logoutAuto? — então nem dá pra um consumidor ligar o caminho forçado via initAccounts().

E no base: deleteJwtCookie() fixa POST /api/auth/logout sem ramificação, e não existe rota /api/auth/logout-auto em app/routes/api/auth/.

Resultado: logout voluntário e forçado são indistinguíveis pro BFF. Ligar isso exige uma rota proxy no base e um seam novo no core (ou uma chamada server-side ao AuthService do root) — não é só adicionar a rota.

Detalhe completo em @cactus-agents/accounts. :::

:::caution Comentário desatualizado no código app/components/user/panel/UserPanelLogout.tsx afirma que handleUnauthorized usa /api/auth/logout-auto "quando a brand opta via authFeaturesConfig.useAutoLogoutEndpoint". Essa flag não existe — a única ocorrência dela em app/ e overrides/ é dentro desse próprio comentário. Não confie nele. :::

Validações pós-login

Após login (ou refresh com cookie válido), o sistema de validações avalia pendências obrigatórias:

setAuthUser(user, userInfo)

useValidationRuntimeSync
├── buildValidationSnapshot(user, userInfo)
└── fetchAllValidations(config, snapshot)

allValidations.hasPending?
├── force / regulatory / global → ValidationBlockerOverlay
└── false → navegação normal

Três paths de bloqueio, em ordem de prioridade:

  1. forceforceRequestKyc = true no perfil (backend forçou KYC)
  2. regulatoryshowPendingDataFlow = true (dados regulatórios pendentes)
  3. globalconfig.global.active = true e módulos do global não satisfeitos

Com pendência, o DefaultLayout renderiza o ValidationBlockerOverlay sobre o site (blur + anti-tamper). Além do bloqueio global, cada contexto (casino, deposit, withdraw, …) é avaliado separadamente via ValidationStepsModal quando o usuário tenta a ação.

Ver Validações para detalhes completos.

Checklist ao criar componente auth-aware

  • O gate vem de useAuthGate() — não de useRouteLoaderData, não de useAccountsStore.isAuthenticated cru?
  • user/userInfo/wallet vêm de useAccountsStore?
  • Não há cache de user.id em ref/state que ignora mudanças?
  • Side-effects dependentes do usuário recomputam quando user?.id muda?
  • Nada derivado do usuário foi adicionado ao loader do _layout? (o teste no-user-data-in-ssr.test.ts falha)

Arquivos relevantes

ArquivoPapel
app/hooks/useAuthGate.tsGate canônico de auth pra UI
app/hooks/useAuthInit.tsHidratação client-side (cookie → /api/auth/profile)
app/hooks/useAuthProfileSync.tsMantém o profile atualizado (com TTL memo)
app/hooks/useAuthLogoutCleanup.tsLimpeza na transição true → false
app/hooks/useValidationRuntimeSync.tsComputa allValidations e sincroniza
app/components/auth/AuthInitializer.tsxMonta os hooks de bootstrap
app/components/user/WalletInitializer.tsxInicializa wallet (lazy, só logado)
app/components/auth/LoginModal.tsxModal de login (POST /api/auth/login)
app/components/auth/TwoFactorStep.tsxPasso de 2FA (email / sms)
app/components/auth/RegisterModal.tsxModal de registro
app/components/auth/SessionExpiredBanner.tsxBanner de sessão expirada
app/routes/api/auth/login.tsEnvelope FormErrorCode + retry de réplica
app/routes/api/auth/logout.tsLogout + makeDeleteAllAuthHeaders()
app/utils/cookie.server.tsOs três cookies + AUTH_COOKIE_NAMES
app/utils/cookie.client.tshasAuthFlagCookie, AUTH_FLAG_CLEARED_EVENT, gm_id
app/utils/api-fetch.client.tshandleUnauthorized() — chokepoint de 401/440
app/store/authModal.tsEstado do modal de auth (base, não é o core)
app/store/validationRuntime.tsResultado das validações
app/routes/__tests__/no-user-data-in-ssr.test.tsLeak canary do SSR

Estado de auth, usuário, wallet, transações e coins vive em useAccountsStore (@cactus-agents/accounts/react) — não existe app/store/auth.ts nem app/store/wallet.ts.

Docs relacionadas