Pular para o conteúdo principal
Versão: main (dev) 🚧

ADR-006 — Local ASP.NET Identity + dual-scheme (BFF cookie + Bearer JWKS) como default

Date: 2026-06-11 Status: Accepted (seção CSRF parcialmente superseded por ADR-008) Supersedes: ADR-003 Pareado com: DevPack ADR-005

Context

ADR-003 ratificou o template como resource server config-driven: valida JWT de um IdP externo via Authentication:Jwt:{Authority, Audience}, deliberadamente não sendo emissor. Cobriu bem o caso "API interna atrás de um IdP corporativo", mas deixava todo projeto sem IdP pronto tendo que provisionar um servidor de identidade separado antes do primeiro login.

A nova fase do framework quer o template self-contained para autenticação: usuários, roles e senhas no próprio store (ASP.NET Core Identity local), sem depender de IdP externo nem subir um servidor OIDC completo (OpenIddict foi avaliado e descartado no escopo — peso/superfície desproporcionais).

Duas realidades de cliente precisam coexistir no mesmo backend:

  • SPA same-origin (browser): guardar JWT em localStorage é passivo de XSS. O padrão seguro é BFF — cookie de sessão HttpOnly, sem token algum em JavaScript.
  • Mobile e service-to-service: não há cookie ambiente; o natural é Bearer com JWT validável por terceiros (JWKS).

Decision

Identity local + dois schemes coexistindo passam a ser o default do canon. O caminho resource-server-externo do ADR-003 vira opt-in.

Identity store local

  • AppUser : BaseIdentityUser e AppRole : BaseIdentityRole (bases do DevPack 10.4.0).
  • AppDbContext : BaseIdentityDbContext<AppUser, AppRole> — tabelas de Identity no schema dedicado identity (sem prefixo AspNet: identity.Users, identity.Roles, …) convivem com Orders; migration de Identity adicionada.
  • Seed canônico: usuário Admin + roles (Admin/Operator/Viewer). A tabela RolePermissions (role→permission) e o DbRolePermissionResolver do canon permanecem — só a origem da participação em role passa a ser identity.UserRoles.

Dois schemes via seletor

builder.Services.AddAuthentication("smart")
.AddPolicyScheme("smart", "Cookie ou Bearer", o =>
o.ForwardDefaultSelector = ctx =>
ctx.Request.Headers.Authorization.ToString().StartsWith("Bearer ", StringComparison.Ordinal)
? JwtBearerDefaults.AuthenticationScheme
: CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, /* HttpOnly, Secure, SameSite */)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, /* valida self-issued via JWKS */);
  • BFF (browser): cookie de sessão puroSignInAsync no login, zero JWT no JS. CSRF coberto por antiforgery (SameSite + token nos verbos de escrita). Nota: o gatilho de validação e a emissão do token foram revisados em ADR-008 (validação gated por sessão autenticada, não por presença do cookie; emissão de token anônima) para corrigir um deadlock de login com cookie inválido/expirado.
  • Bearer (mobile + S2S): JWT assimétrico RSA/ECDSA, validado pelo endpoint JWKS publicado pela própria API.

Endpoints (/v1/auth/*)

EndpointClienteFaz
POST /session · /session/refresh · /session/logoutbrowserSignInAsync/SignOutAsync (cookie)
POST /token · /token/refreshmobilecredenciais → {access, refresh}; rotação de refresh
POST /clients/tokenserviço (S2S)clientId/secret → JWT de serviço (client-credentials mínimo)
GET /.well-known/jwks.jsonqualquer validadorchaves públicas
POST /register · /change-passwordgestão de conta

Convergência downstream (inalterada)

Os dois schemes desembocam em HttpContext.UserUseDevPackIdentityContextICurrentUser. Login (cookie e token) baka as roles no principal/claims, então RoleBasedPermissionEvaluator + RolePermissions + Permissions.cs e os RequireAuthorization(Permissions.X) dos endpoints funcionam sem alteração. Endpoint não sabe — nem precisa — qual scheme autenticou.

Self-issued config & fail-early

JwtOptions é reformulado de {Authority, Audience} (externo) para {Issuer, Audience, AccessTokenLifetime, SigningKey}. Dev usa RSA ephemeral por default; produção exige chave persistente configurada (PEM/Key Vault) ou a startup falha — mantendo a garantia "typo/config faltando = erro na startup, não 401 silencioso" do ADR-003.

Opt-in para resource-server externo

O caminho do ADR-003 segue suportado via flag de template --auth external-jwt (ou swap documentado do bloco de auth no Program.cs), para quem realmente tem um IdP corporativo e não quer Identity local.

Consequences

Positivas:

  • Template autentica out-of-the-box, sem provisionar IdP externo.
  • Browser seguro por construção (nenhum token em JS); mobile/S2S cobertos por Bearer/JWKS sem servidor OIDC.
  • Uma única identidade downstream — todo o canon de autorização é reaproveitado.

Negativas:

  • Superfície maior no default: endpoints de conta/token, gestão de chave assimétrica, antiforgery. Mitigação: defaults seguros + falha-cedo + testes E2E do fluxo.
  • dotnet new smoke + build -c Release + test precisam passar já emitindo token — exige dev signing key default funcional.
  • Worker (sem HttpContext) autentica como system user no ICurrentUser.
  • Consumidores que viviam no caminho ADR-003 migram via --auth external-jwt — documentar no README e CHANGELOG.

Alternatives considered

OpçãoPor que descartada
Manter ADR-003 default + Identity como opt-inDecisão de escopo: queremos auth funcionando no default, não atrás de uma flag.
Scheme único Bearer com JWT em localStoragePassivo de XSS no browser — exatamente o que o BFF evita.
Só cookie (sem Bearer)Não atende mobile nem service-to-service.
Identity + OpenIddict (servidor OIDC completo)Peso/superfície desproporcionais (flows, discovery, encryption certs) para o ganho. Reavaliável em fase futura.
Assinatura simétrica do JWT BearerS2S exige validação sem o segredo → assimétrica + JWKS.

References