ADR-006 — Local ASP.NET Identity + dual-scheme (BFF cookie + Bearer JWKS) como default
Date: 2026-06-11 Status: Accepted 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ãoHttpOnly, 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 : BaseIdentityUsereAppRole : BaseIdentityRole(bases do DevPack 10.4.0).AppDbContext : BaseIdentityDbContext<AppUser, AppRole>— tabelas de Identity no schema dedicadoidentity(sem prefixoAspNet:identity.Users,identity.Roles, …) convivem comOrders; migration de Identity adicionada.- Seed canônico: usuário Admin + roles (Admin/Operator/Viewer). A tabela
RolePermissions(role→permission) e oDbRolePermissionResolverdo canon permanecem — só a origem da participação em role passa a seridentity.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 puro —
SignInAsyncno login, zero JWT no JS. CSRF coberto por antiforgery (SameSite+ token nos verbos de escrita). - Bearer (mobile + S2S): JWT assimétrico RSA/ECDSA, validado pelo endpoint JWKS publicado pela própria API.
Endpoints (/v1/auth/*)
| Endpoint | Cliente | Faz |
|---|---|---|
POST /session · /session/refresh · /session/logout | browser | SignInAsync/SignOutAsync (cookie) |
POST /token · /token/refresh | mobile | credenciais → {access, refresh}; rotação de refresh |
POST /clients/token | serviço (S2S) | clientId/secret → JWT de serviço (client-credentials mínimo) |
GET /.well-known/jwks.json | qualquer validador | chaves públicas |
POST /register · /change-password | — | gestão de conta |
Convergência downstream (inalterada)
Os dois schemes desembocam em HttpContext.User → UseDevPackIdentityContext → ICurrentUser. 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 newsmoke +build -c Release+testprecisam passar já emitindo token — exige dev signing key default funcional.- Worker (sem
HttpContext) autentica como system user noICurrentUser. - Consumidores que viviam no caminho ADR-003 migram via
--auth external-jwt— documentar no README e CHANGELOG.
Alternatives considered
| Opção | Por que descartada |
|---|---|
| Manter ADR-003 default + Identity como opt-in | Decisão de escopo: queremos auth funcionando no default, não atrás de uma flag. |
Scheme único Bearer com JWT em localStorage | Passivo 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 Bearer | S2S exige validação sem o segredo → assimétrica + JWKS. |
References
- ADR-003 — JWT Bearer config-driven (superseded).
- ADR-004 — DB-backed role/permissions (preservado).
- ADR-005 — Examples as opt-in reference (
--authé flag de scaffold, não example). - DevPack ADR-005 — Identity store base types & self-issued tokens.
- CLAUDE.md § Authentication (a atualizar) e README § Configuração Mínima.