Appearance
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 defeature/doc-fluxosé idêntico ao source de develop —git diff --stat origin/develop -- srcvazio). Natureza: planejamento. Nenhum código foi alterado.
1. Estado atual verificado (código)
| Fato | Evidência |
|---|---|
| Nenhum código de cookie existe | busca por Response.Cookies/CookieOptions/HttpOnly/SameSite em src = 0 ocorrências |
Access token lido só do header Authorization | OnMessageReceived vazio — InfrastructureServiceRegistration.cs:503-506 |
| Refresh emitido no corpo JSON | AuthenticationResult.RefreshToken — AuthenticationCommands.cs:130; populado em RefreshTokenCommandHandler.cs:400, e nos handlers de Login/SocialLogin/SwitchTenant/CompleteFirstLogin/VerifyMfaChallenge |
| Refresh lido do corpo | RefreshToken([FromBody] RefreshTokenRequest) → request.RefreshToken — AuthController.cs:149-155; DTO em AuthHttpContracts.cs:28-32 |
| Serialização do resultado | ToActionResult<T> → Ok(result.Data) — GrydBaseController.cs:51-58 (serializa o AuthenticationResult inteiro) |
| Logout já é bearer/stateless | AuthController.cs:201-246 (comentário "CSRF-proof: Token in Authorization header") |
| CORS já habilita credenciais | WithOrigins(...).AllowAnyMethod().AllowAnyHeader().AllowCredentials() — CorsServiceExtensions.cs:49-52, allowlist sem wildcard |
| Sem antiforgery/CSRF | nenhum AddAntiforgery no projeto |
| Refresh token é um JWT, 7 dias, rotação single-use | StatelessRefreshTokenService.cs:66-115 (expirationDays=7, RotateRefreshTokenAsync) |
Pipeline: UseCors já no lugar certo | GrydAuthServiceCollectionExtensions.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-84 → VerifyMfaChallengeCommand.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çãoPor 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.
3.2 IRefreshTokenCookieWriter + RefreshTokenCookieWriter — fonte única dos atributos do cookie
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.cs — có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).
3.4 IAuthResponseFactory + AuthResponseFactory — composição (cookie + corpo) em um só ponto
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 MfaController — não colocar na GrydBaseController genérica (poluiria a base compartilhada com lógica específica de auth, violando SRP da base).
Alternativa considerada: um
IAsyncResultFilteraplicado por[ServiceFilter]no controller. É igualmente DRY e mais "cross-cutting", porém opera por reflexão sobreObjectResult.Valuee é menos explícito/tipado. Recomenda-se a factory por aderir ao padrãoToActionResultjá existente no código (consistência/Liskov) e por ser rastreável. O filter fica como plano B.
4. Inventário arquivo-a-arquivo
Emissão do cookie (§3.1 do escopo)
| Arquivo | Mudança |
|---|---|
GrydAuth.API/Configuration/RefreshTokenCookieOptions.cs | novo — options |
GrydAuth.API/Security/Cookies/IRefreshTokenCookieWriter.cs + RefreshTokenCookieWriter.cs | novo — writer/reader/delete |
GrydAuth.API/Contracts/AuthenticationResponse.cs + AuthenticationResponseMapper.cs | novo — DTO de fio sem refresh + mapper |
GrydAuth.API/Security/AuthResponseFactory.cs (+ IAuthResponseFactory) | novo — composição cookie+corpo |
AuthController.cs:56-140,263-278,524-561 | trocar return ToActionResult(result) por return _authResponse.Build(this, result) em Login, SocialLogin, SwitchTenant, CompleteFirstLogin; injetar IAuthResponseFactory no ctor |
MfaController.cs:70-84 | idem 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)
| Arquivo | Mudança |
|---|---|
AuthController.cs:147-181 | ler 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-32 | tornar 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ção | nenhuma 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)
| Arquivo | Mudança |
|---|---|
AuthController.cs:201-246 | ao 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:
- Mínimo:
SameSite=Strict(já coberto porRefreshTokenCookieOptions). - Reforço recomendado (double-submit / header custom): novo
GrydAuth.API/Security/Csrf/RequireCsrfHeaderAttribute.cs(umIAsyncActionFilter) que exige um header que só JS same-origin consegue anexar (ex.:X-Gryd-Csrfcasado com um cookie legível não-HttpOnly, ou exigência deOrigin/Sec-Fetch-Siteallowlisted). 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.
- Só necessário obrigatoriamente se topologia for cross-site (
CORS / config / pipeline (§3.5–3.6 do escopo)
| Item | Situação | Açã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 |
UseCookiePolicy | não existe | opcional; não é necessário para Set-Cookie explícito. Atenção: Secure=true exige HTTPS inclusive em dev |
OnMessageReceived | vazio (: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(ouLax);Domain=.gryd.comse compartilhar subdomínios. CSRF resolvido só comSameSite→ §4 reforço opcional. - Cross-site (domínios diferentes):
SameSite=Strict/Laxnão envia o cookie → exigeSameSite=None; Securee 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.
6. Expiração do cookie — nota de precisão
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
ExpirationDaysdas 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 RefreshTokenExpiresAtaoAuthenticationResulte popular nos handlers, para o writer casarExpiresexatamente. 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
AuthenticationResultganham o comportamento só usando a factory; topologia/SameSitemudam por config. - LSP: a factory segue o contrato/estilo de
ToActionResultda base; controllers permanecem substituíveis. - ISP:
AuthenticationResponseexpõ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_refreshcomHttpOnly; Secure; SameSite; Path=/api/v1/auth. - corpo JSON não contém a chave
refreshTokenem nenhum desses endpoints. /refreshautentica via cookie com body semrefreshToken; body só comtenantIdfunciona./refreshsem cookie → 401.- rotação: cada
/refreshreescreve o cookie (novo valor); reuso do anterior → falha (anti-reuse). /logoutemiteSet-Cookiede expiração (mesmos atributos) e limpa o cookie.- CSRF: se topologia cross-site,
/refreshsem 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)
- Topologia de domínio (same-site vs cross-site)? — trava
SameSite/Domaine a obrigatoriedade do reforço CSRF. - 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. - Estratégia CSRF:
SameSite=Strictpuro ou double-submit/header custom? - Confirmar nome/path:
gryd_refresh+/api/v1/auth. - Incluir MFA verify agora (recomendado) ou tratar no épico de MFA? (deixar de fora mantém um vazamento no corpo.)
- Requisito de expiração exata do refresh no cliente? Se sim, adotar o enhancement da §6 (carregar
RefreshTokenExpiresAt).