Skip to content

Levantamento de mudanças — Refresh token em cookie HttpOnly (GrydAuth v5)

Escopo de origem: requisito do time gryd.ui (2026-07-02) — mover o refresh token do corpo JSON para cookie HttpOnly; Secure; SameSite, mantendo o access token em memória (Bearer). Base analisada: origin/develop (working tree de feature/doc-fluxos é idêntico ao source de develop — git diff --stat origin/develop -- src vazio). Natureza: planejamento. Nenhum código foi alterado.


1. Estado atual verificado (código)

FatoEvidência
Nenhum código de cookie existebusca por Response.Cookies/CookieOptions/HttpOnly/SameSite em src = 0 ocorrências
Access token lido só do header AuthorizationOnMessageReceived vazio — InfrastructureServiceRegistration.cs:503-506
Refresh emitido no corpo JSONAuthenticationResult.RefreshTokenAuthenticationCommands.cs:130; populado em RefreshTokenCommandHandler.cs:400, e nos handlers de Login/SocialLogin/SwitchTenant/CompleteFirstLogin/VerifyMfaChallenge
Refresh lido do corpoRefreshToken([FromBody] RefreshTokenRequest)request.RefreshTokenAuthController.cs:149-155; DTO em AuthHttpContracts.cs:28-32
Serialização do resultadoToActionResult<T>Ok(result.Data)GrydBaseController.cs:51-58 (serializa o AuthenticationResult inteiro)
Logout já é bearer/statelessAuthController.cs:201-246 (comentário "CSRF-proof: Token in Authorization header")
CORS já habilita credenciaisWithOrigins(...).AllowAnyMethod().AllowAnyHeader().AllowCredentials()CorsServiceExtensions.cs:49-52, allowlist sem wildcard
Sem antiforgery/CSRFnenhum AddAntiforgery no projeto
Refresh token é um JWT, 7 dias, rotação single-useStatelessRefreshTokenService.cs:66-115 (expirationDays=7, RotateRefreshTokenAsync)
Pipeline: UseCors já no lugar certoGrydAuthServiceCollectionExtensions.cs:156 (UseGrydAuth chama UseCors("GrydAuthCors") antes do token middleware)

Ponto de emissão adicional não listado no escopo: POST /api/v1/mfa/challenge/verify também retorna AuthenticationResult com RefreshToken no corpo (MfaController.cs:70-84VerifyMfaChallengeCommand.cs:248-251). São 6 pontos de emissão, não 5. Ver §7.


2. Decisão arquitetural central (clean architecture)

O cookie é uma preocupação de transporte HTTP e deve viver inteiramente na camada GrydAuth.API. Application e Domain permanecem ignorantes de cookies: os handlers continuam sendo a fonte de verdade e retornam AuthenticationResult com RefreshToken preenchido. A camada de API decide o transporte — retira do corpo e grava no cookie.

Isso preserva a Regra de Dependência (dependências apontam para dentro; API→Application, nunca o inverso) e mantém zero mudança em handlers, comandos e no AuthenticationResult para o mecanismo principal. Todo o comportamento novo entra por composição na borda.

Anti-padrão a evitar: injetar HttpResponse/IHttpContextAccessor nos handlers para gravar cookie. Isso quebraria a Regra de Dependência e o SRP dos handlers (que passariam a conhecer HTTP).


3. Componentes novos a introduzir (todos em GrydAuth.API + config)

3.1 RefreshTokenCookieOptions — Options pattern (config-driven)

Novo: GrydAuth.API/Configuration/RefreshTokenCookieOptions.cs, bind da seção GrydAuth:RefreshTokenCookie.

Name           = "gryd_refresh"
Path           = "/api/v1/auth"
SameSite       = Strict            // configurável — §3.6 do escopo (topologia de domínio)
Secure         = true
Domain         = null              // ex.: ".gryd.com" para compartilhar subdomínios
ExpirationDays = 7
Enabled        = true              // kill-switch / transição

Por quê: resolve a decisão de topologia de domínio (§3.6) e a estratégia SameSite (§3.4) sem mudança de código — só configuração por ambiente. Consistente com o padrão já usado no módulo (MultiTenancyOptions, CircuitBreakerOptions, PasswordPolicyOptions). OCP e SRP.

Novo: GrydAuth.API/Security/Cookies/.

csharp
public interface IRefreshTokenCookieWriter
{
    void Write(HttpResponse response, string refreshToken, DateTimeOffset expiresAt);
    string? Read(HttpRequest request);
    void Delete(HttpResponse response);
}

Depende de IOptions<RefreshTokenCookieOptions>. Centraliza os atributos do cookie em um único lugar (DRY)Write, Read (§3.2) e Delete (§3.3) do escopo reusam o mesmo Name/Path/SameSite/Secure/Domain. Garante que Delete use atributos idênticos ao Write (requisito para o browser expirar o cookie). Testável isoladamente. É um Adapter sobre CookieOptions.

3.3 AuthenticationResponse (contrato de fio) + mapper — remoção do refresh do corpo

Novo: GrydAuth.API/Contracts/AuthenticationResponse.cscópia de AuthenticationResult sem a propriedade RefreshToken, mais AuthenticationResponseMapper.ToResponse(AuthenticationResult).

Por quê um DTO dedicado em vez de anular a propriedade: anular RefreshToken ainda serializa "refreshToken": null no corpo (a chave continua presente). Um DTO de resposta próprio omite o campo por completo, satisfazendo literalmente o critério de aceite "não aparece no corpo JSON". É a Interface Segregation aplicada ao contrato de fio (o cliente v5 não deve nem ver o campo). O mapper é único e reusado pelos 6 endpoints (DRY).

Novo: GrydAuth.API/Security/AuthResponseFactory.cs.

csharp
IActionResult Build(ControllerBase controller, Result<AuthenticationResult> result);

Responsabilidade única: em caso de sucesso, se RefreshToken != null, grava o cookie via IRefreshTokenCookieWriter e devolve Ok(AuthenticationResponse); em falha, delega a ToErrorActionResult. Substitui as ~6 chamadas repetidas de ToActionResult(result) nos endpoints de auth (DRY), mantendo a escrita do cookie num único lugar (SRP). Injetado em AuthController e MfaControllernão colocar na GrydBaseController genérica (poluiria a base compartilhada com lógica específica de auth, violando SRP da base).

Alternativa considerada: um IAsyncResultFilter aplicado por [ServiceFilter] no controller. É igualmente DRY e mais "cross-cutting", porém opera por reflexão sobre ObjectResult.Value e é menos explícito/tipado. Recomenda-se a factory por aderir ao padrão ToActionResult já existente no código (consistência/Liskov) e por ser rastreável. O filter fica como plano B.


4. Inventário arquivo-a-arquivo

ArquivoMudança
GrydAuth.API/Configuration/RefreshTokenCookieOptions.csnovo — options
GrydAuth.API/Security/Cookies/IRefreshTokenCookieWriter.cs + RefreshTokenCookieWriter.csnovo — writer/reader/delete
GrydAuth.API/Contracts/AuthenticationResponse.cs + AuthenticationResponseMapper.csnovo — DTO de fio sem refresh + mapper
GrydAuth.API/Security/AuthResponseFactory.cs (+ IAuthResponseFactory)novo — composição cookie+corpo
AuthController.cs:56-140,263-278,524-561trocar return ToActionResult(result) por return _authResponse.Build(this, result) em Login, SocialLogin, SwitchTenant, CompleteFirstLogin; injetar IAuthResponseFactory no ctor
MfaController.cs:70-84idem em VerifyChallenge (ver §7)
registro DI (GrydAuthServiceCollectionExtensions / composition root)services.Configure<RefreshTokenCookieOptions>(...), AddScoped<IRefreshTokenCookieWriter,...>, AddScoped<IAuthResponseFactory,...>

Leitura do refresh no /refresh (§3.2 do escopo)

ArquivoMudança
AuthController.cs:147-181ler o refresh do cookie: var refresh = _cookieWriter.Read(Request) ?? request.RefreshToken; e montar RefreshTokenCommand.RefreshToken = refresh. Manter fallback ao body apenas enquanto Enabled-transição, depois remover
AuthHttpContracts.cs:28-32tornar RefreshTokenRequest.RefreshToken opcional (string?). Manter TenantId no body — o override de tenant continua vindo do corpo; o corpo do /refresh não fica 100% vazio
rotaçãonenhuma mudança de handler — a factory reescreve o cookie automaticamente porque /refresh também retorna AuthenticationResult com o novo refresh rotacionado (RefreshTokenCommandHandler.cs:106,400)

Logout (§3.3 do escopo)

ArquivoMudança
AuthController.cs:201-246ao final (todos os caminhos, inclusive o idempotente), chamar _cookieWriter.Delete(Response). A invalidação de sessão por TokenVersion/blacklist já existe — isto só remove o cookie do browser

CSRF no /refresh (§3.4 do escopo)

O /refresh passa a ser cookie-authenticated → sujeito a CSRF (o design bearer atual era imune). Camadas:

  1. Mínimo: SameSite=Strict (já coberto por RefreshTokenCookieOptions).
  2. Reforço recomendado (double-submit / header custom): novo GrydAuth.API/Security/Csrf/RequireCsrfHeaderAttribute.cs (um IAsyncActionFilter) que exige um header que só JS same-origin consegue anexar (ex.: X-Gryd-Csrf casado com um cookie legível não-HttpOnly, ou exigência de Origin/Sec-Fetch-Site allowlisted). Aplicar apenas em [HttpPost("refresh")]. Demais endpoints seguem bearer, não afetados.
    • Só necessário obrigatoriamente se topologia for cross-site (SameSite=None) — ver §5.

CORS / config / pipeline (§3.5–3.6 do escopo)

ItemSituaçãoAção
AllowCredentials() + allowlist explícita✅ já ok (CorsServiceExtensions.cs:49-52)garantir GrydAuth:Cors:AllowedOrigins com as origens exatas de prod/homolog
UseCors antes de auth✅ já ok (GrydAuthServiceCollectionExtensions.cs:156)nenhuma
UseCookiePolicynão existeopcional; não é necessário para Set-Cookie explícito. Atenção: Secure=true exige HTTPS inclusive em dev
OnMessageReceivedvazio (:503)não mexer — access token continua via header Authorization

5. Ponto de decisão que trava atributos (§3.6 do escopo)

A topologia de domínio precisa ser respondida pelo gryd.ui para calibrar SameSite/Domain:

  • Same-site (ex.: app.gryd.com + api.gryd.com): SameSite=Strict (ou Lax); Domain=.gryd.com se compartilhar subdomínios. CSRF resolvido só com SameSite → §4 reforço opcional.
  • Cross-site (domínios diferentes): SameSite=Strict/Lax não envia o cookie → exige SameSite=None; Secure e o reforço CSRF do §4 passa a ser obrigatório.

Como tudo está em RefreshTokenCookieOptions, a escolha é de configuração — o código não muda.


RotateRefreshTokenAsync pode encurtar o TTL do refresh via zero-trust (decision.MaxExpirationDays, StatelessRefreshTokenService.cs:264-273). O AuthenticationResult não carrega a data de expiração do refresh. Duas opções:

  • Pragmática (recomendada): cookie usa ExpirationDays das options (7). Se o token for encurtado, o cookie sobrevive ao token → inócuo (o servidor rejeita o refresh expirado; o cookie apenas fica ocioso).
  • Precisa (enhancement): adicionar DateTime RefreshTokenExpiresAt ao AuthenticationResult e popular nos handlers, para o writer casar Expires exatamente. Custo: pequena mudança na Application em todos os handlers de emissão. Só vale se houver requisito de compliance de expiração exata no cliente.

7. Gap de completude — MFA (fora do escopo declarado, mas necessário)

O escopo marca MFA como fora, mas POST /api/v1/mfa/challenge/verify emite RefreshToken no corpo (VerifyMfaChallengeCommand.cs:248-251). Se esse endpoint não receber o mesmo tratamento, o objetivo de segurança é parcialmente anulado: o fluxo com MFA continuaria vazando o refresh durável no corpo/JS.

Recomendação: incluir VerifyChallenge na mesma refatoração (usa a mesma IAuthResponseFactory — custo marginal ~1 linha). Sem duplicação, graças ao desenho DRY da §3.


8. Mapeamento SOLID / DRY / Clean Arch

  • SRP: cookie (writer), contrato de fio (response+mapper), composição (factory), CSRF (attribute) — cada um com uma responsabilidade. Handlers/Domain intocados.
  • OCP: novos endpoints que retornem AuthenticationResult ganham o comportamento só usando a factory; topologia/SameSite mudam por config.
  • LSP: a factory segue o contrato/estilo de ToActionResult da base; controllers permanecem substituíveis.
  • ISP: AuthenticationResponse expõe ao cliente só o necessário (sem refresh); IRefreshTokenCookieWriter é mínima (Write/Read/Delete).
  • DIP: controllers dependem de abstrações (IAuthResponseFactory, IRefreshTokenCookieWriter), resolvidas por DI; a Regra de Dependência da clean arch é mantida (transporte só na API).
  • DRY: atributos do cookie num único writer; mapeamento de resposta num único mapper; composição num único ponto reusado por 6 endpoints.
  • Design patterns: Options, Adapter (cookie), Factory (resposta), Mapper, opcional Decorator/Filter (CSRF).

9. Plano de testes (critérios de aceite §5 do escopo)

Integração (tests/Integration/GrydAuth.IntegrationTests):

  • login/social-login/switch-tenant/complete-first-login/refresh/mfa-verify setam Set-Cookie: gryd_refresh com HttpOnly; Secure; SameSite; Path=/api/v1/auth.
  • corpo JSON não contém a chave refreshToken em nenhum desses endpoints.
  • /refresh autentica via cookie com body sem refreshToken; body só com tenantId funciona.
  • /refresh sem cookie → 401.
  • rotação: cada /refresh reescreve o cookie (novo valor); reuso do anterior → falha (anti-reuse).
  • /logout emite Set-Cookie de expiração (mesmos atributos) e limpa o cookie.
  • CSRF: se topologia cross-site, /refresh sem header/anti-CSRF → 403.

Unitários: RefreshTokenCookieWriter (atributos por config, Write/Read/Delete simétricos); AuthenticationResponseMapper (não expõe refresh); AuthResponseFactory (sucesso grava cookie + omite refresh; falha vira ProblemDetails).


10. Perguntas em aberto (para o gryd.ui / arquitetura)

  1. Topologia de domínio (same-site vs cross-site)? — trava SameSite/Domain e a obrigatoriedade do reforço CSRF.
  2. Transição cookie-only (recomendado) ou cookie+body temporário? Cookie+body anula o ganho de segurança — só como ponte curta, atrás do flag Enabled.
  3. Estratégia CSRF: SameSite=Strict puro ou double-submit/header custom?
  4. Confirmar nome/path: gryd_refresh + /api/v1/auth.
  5. Incluir MFA verify agora (recomendado) ou tratar no épico de MFA? (deixar de fora mantém um vazamento no corpo.)
  6. Requisito de expiração exata do refresh no cliente? Se sim, adotar o enhancement da §6 (carregar RefreshTokenExpiresAt).

Released under the MIT License.