ADR-008 — CSRF: emissão anônima de token + validação gated por sessão autenticada
Date: 2026-06-16 Status: Accepted Supersedes: seção CSRF de ADR-006 (o restante do ADR-006 permanece vigente).
Context
O primeiro projeto consumidor do template reportou um deadlock de login no caminho BFF (browser, cookie HttpOnly):
Um cookie de sessão inválido/expirado tranca o usuário fora do login. Não dá pra logar (o
BffAntiforgeryMiddlewareexige token CSRF porque o cookie existe) nem pra obter o token (GET /v1/auth/csrfexigia autenticação). A única saída é limpar cookies manualmente.
A análise (ver docs/analysis/csrf-auth-deadlock-analysis.md) confirmou o bug como uma incoerência interna entre duas peças do canon, cada uma defensável isoladamente:
| Peça | Critério original |
|---|---|
BffAntiforgeryMiddleware | exigia CSRF quando o cookie estava presente (Cookies.ContainsKey) |
GET /v1/auth/csrf | só emitia token para quem estava autenticado (.RequireAuthorization()) |
Um cookie presente porém inválido (não autentica) satisfazia o gatilho da primeira peça e reprovava no requisito da segunda. Como o POST /v1/auth/session (login) é um verbo de escrita que passa pelo middleware antes da autorização do endpoint, ele passava a exigir um token CSRF que o usuário não tinha como obter — e o logout (também RequireAuthorization) não destravava, pois o cookie é HttpOnly (JS não o apaga).
O gatilho é comum: o cookie de sessão é emitido sem IsPersistent (session cookie, sem Max-Age), então o browser o mantém até fechar a janela enquanto o ticket interno expira em 8h (ExpireTimeSpan). Uma aba aberta por mais de 8h já reproduz: cookie presente, ticket vencido. Rotação/perda da chave de Data Protection (multi-instância sem keyring compartilhado, restart de container, expiração de chave) e cookie adulterado produzem o mesmo estado.
O conceito de segurança correto: CSRF só existe quando há credencial ambiente que confere autoridade. Um cookie inválido não confere autoridade nenhuma — não há sessão a sequestrar, logo não há risco de CSRF. O canon, ao escrever a regra como "cookie presente", aproximou "tem credencial ambiente com autoridade" por "tem o cookie", e essas duas coisas divergem exatamente no estado presente-mas-inválido. Note que o canon já isentava o login do caso "sem cookie nenhum" — logo, exigir CSRF no login só quando há um cookie morto presente nunca foi uma decisão de segurança deliberada.
Decision
Duas mudanças complementares, ambas no template (o BffAntiforgeryMiddleware e o AuthEndpoints vivem em ITLab.Template.Next.Api; o DevPack não participa do gatilho de CSRF).
B — validação gated por sessão autenticada, não por presença
BffAntiforgeryMiddleware.RequiresValidation passa a exigir CSRF apenas quando o request carrega o cookie de sessão E está autenticado:
return context.Request.Cookies.ContainsKey(AuthCookieDefaults.SessionCookieName)
&& context.User.Identity?.IsAuthenticated == true;
- O estado autenticado está disponível porque
UseAuthenticationroda antes deUseBffAntiforgeryno pipeline (Program.cs). - A presença do cookie permanece como pré-filtro barato — e mantém o
TestAuthenticationHandler(headerX-Test-Roles, sem cookie) isento, preservando os testes de escrita do canon. - Cookie stale/inválido → não autenticado → CSRF não exigido → login (e logout) voltam a funcionar; escritas após login válido seguem protegidas normalmente.
A — emissão de token CSRF anônima
GET /v1/auth/csrf passa de .RequireAuthorization() para .AllowAnonymous(). Emitir um token antiforgery (IAntiforgery.GetAndStoreTokens) não requer identidade — depende apenas do cookie antiforgery próprio (.AspNetCore.Antiforgery.*), distinto do cookie de sessão. O SPA passa a sempre conseguir um token antes de qualquer POST.
As duas são compatíveis e adotadas juntas: B corrige a causa-raiz (alinha o código à intenção já documentada de "CSRF só em escrita cookie-borne com autoridade"); A é a defesa adicional convencional (endpoint emissor de antiforgery anônimo é o padrão da plataforma).
Consequences
Positivas:
- Elimina o deadlock: cookie inválido/expirado não tranca mais o login. Recuperação automática sem limpar cookies à mão.
- O gatilho do middleware passa a expressar literalmente o conceito de segurança ("CSRF só com autoridade ambiente").
- Nenhuma regressão de proteção: escrita por sessão autenticada sem token continua
400(coberto porAntiforgeryTests+BffAntiforgeryMiddlewareTests).
Negativas / limitações:
- O harness de teste de integração autentica todo request (
TestAuthenticationHandlerretorna sempreSuccess), então não consegue exercitar o caminho "cookie presente mas não-autenticado". A verificação fiel da Option B vive em unit tests do middleware (BffAntiforgeryMiddlewareTests), comHttpContextconstruído à mão; a Option A é assertada via metadata do endpoint (Csrf_Endpoint_AllowsAnonymous), não por round-trip HTTP. - O login segue não protegido por CSRF (como já era no caso sem cookie). É um trade-off aceito do canon — "login CSRF" não está no modelo de ameaça do BFF same-origin (ADR-007) com
SameSite=Lax.
Sem impacto:
- SPA (
Spa/src/lib/api/csrf.ts) não muda: já buscava o token sem premissa de identidade; com a Option A a busca passa a funcionar antes do login. - DevPack: nenhuma alteração nem bump de versão (correção 100% template-side).
Alternatives considered
| Opção | Por que não isolada |
|---|---|
| Só A (endpoint anônimo) | Resolve o sintoma, mas deixa o gatilho do middleware ainda por "presença" — CSRF "teatral" num login que o canon nem pretende proteger. |
| Só B (gating por autenticação) | Suficiente para destravar, mas manter o endpoint emissor atrás de auth contraria o padrão usual da plataforma e é gratuito. Adotar A junto é barato e defensivo. |
Gating por IsAuthenticated sem o pré-filtro de cookie | Quebraria os testes do canon: o TestAuthenticationHandler autentica todo request sem cookie → CSRF passaria a ser exigido em toda escrita de teste. O && com ContainsKey é o que preserva a isenção do scheme de teste. |
Expirar o cookie por Max-Age (cookie persistente) para o browser apagá-lo no vencimento | Não cobre rotação de chave nem adulteração; muda a semântica de sessão (persistente vs. de sessão) por um motivo que não é o do bug. |
References
- Análise — Deadlock de CSRF/login com cookie de sessão inválido.
- ADR-006 — Local Identity + dual-scheme (seção CSRF superseded por este ADR).
- ADR-007 — Frontend SPA served same-origin (modelo de ameaça same-origin +
SameSite). - Código:
Api/Middleware/BffAntiforgeryMiddleware.cs,Api/Endpoints/AuthEndpoints.cs(/csrf). - Testes:
Integration.Tests/Identity/BffAntiforgeryMiddlewareTests.cs,Integration.Tests/Identity/AntiforgeryTests.cs.