Orechio.Platform.Bff.Account
0.6.0
dotnet add package Orechio.Platform.Bff.Account --version 0.6.0
NuGet\Install-Package Orechio.Platform.Bff.Account -Version 0.6.0
<PackageReference Include="Orechio.Platform.Bff.Account" Version="0.6.0" />
<PackageVersion Include="Orechio.Platform.Bff.Account" Version="0.6.0" />
<PackageReference Include="Orechio.Platform.Bff.Account" />
paket add Orechio.Platform.Bff.Account --version 0.6.0
#r "nuget: Orechio.Platform.Bff.Account, 0.6.0"
#:package Orechio.Platform.Bff.Account@0.6.0
#addin nuget:?package=Orechio.Platform.Bff.Account&version=0.6.0
#tool nuget:?package=Orechio.Platform.Bff.Account&version=0.6.0
Orechio.Platform.Bff.Account
O BFF de conta compartilhado dos produtos Orechio: verificação de cadastro, primeira filial, recuperação de senha e convite — montado no domínio do próprio produto.
Por que é um pacote, e não um account.orechio.io
Decidido em ADR-019.
O modelo do ecossistema já é BFF por produto + cookie first-party. Uma aplicação de conta
separada quebraria isso: ela teria de devolver a sessão ao produto, e só há dois caminhos —
um handoff cross-domain (um pseudo-OIDC caseiro, com replay, fixação e redirect_uri para
acertar) ou um segundo login. Nenhum dos dois se paga hoje.
Montando as rotas no domínio do produto, o cookie HttpOnly nasce ali e não há handoff.
account.orechio.io está deferido, com gatilho nomeado: integrador terceiro, ou SSO
corporativo. Aí existe IdP de verdade e a federação passa a ter contrapartida.
Como o host monta
builder.Services.AddOrechioAccountBff(options =>
{
options.PlatformUrl = "https://api.example.com"; // origem, sem caminho nem barra final
options.ApplicationSlug = "meu-produto"; // o MESMO slug do signup/login
options.RoutePrefix = "/api/account"; // default; customizável
});
// As duas pontas do modelo de sessão são SUAS:
builder.Services.AddSingleton<ISessionCookieWriter, MinhaSessao>();
builder.Services.AddSingleton<IAccountSessionAccessor, MinhaSessao>();
var app = builder.Build();
app.MapOrechioAccountEndpoints();
AddOrechioAccountBff valida na composição de DI, não no primeiro request: um BFF que
sobe verde e só quebra quando o primeiro cliente tenta se cadastrar esconde o defeito no pior
momento possível.
O contrato de sessão
O pacote não impõe modelo de sessão, porque os produtos não têm um só. Medido: o BFF do Store guarda os tokens em cookies HttpOnly; o do Portal guarda um id opaco e mantém os tokens no Redis. Os dois estão certos para o que fazem.
public interface ISessionCookieWriter
{
void Establish(HttpContext context, TokenPair tokens); // AccessToken, RefreshToken, ExpiresIn
void Clear(HttpContext context);
}
public interface IAccountSessionAccessor
{
ValueTask<string?> GetAccessTokenAsync(HttpContext context, CancellationToken ct = default);
}
ISessionCookieWriter não tem método de leitura, e a ausência é o contrato: o pacote
entrega os tokens uma vez e nunca os lê de volta — ler de volta seria reimplementar a sessão
do host em vez de usá-la. Ler para encaminhar um request é outra operação, com outro dono,
e por isso vive em IAccountSessionAccessor (usado pelo onboarding/branch e pelas rotas de
cobrança).
Também não há controle de nome, path, domínio, SameSite ou TTL de cookie — decisões do
host, que ele já tomou — nem qualquer menção a XSRF: o host já emite e valida o dele, e dois
tokens CSRF disputando o mesmo cookie resolvem-se pelo último a escrever, silenciosamente.
Endpoints
Todos anônimos — são os fluxos de quem ainda não tem sessão.
| Rota | Chama na Platform | Devolve |
|---|---|---|
POST {prefix}/verify-email |
GET /auth/verify-email?token= |
{ status, provisioningRequired } |
POST {prefix}/onboarding/branch |
POST /auth/onboarding/initial-branch |
AccountCompletionResult |
POST {prefix}/forgot-password |
POST /auth/forgot-password |
202 opaco, repassado |
POST {prefix}/reset-password |
POST /auth/reset-password |
{ message } |
POST {prefix}/accept-invite |
POST /auth/accept-invite |
{ message } |
POST {prefix}/resend-verification |
POST /auth/resend-verification |
202 opaco, repassado |
provisioningRequired — por que o BFF precisa existir
O corpo de verify-email da Platform não tem esse campo. O estado vive nas claims do
access token (provisioning, provisioning_scope). Como o token nunca pode chegar ao
frontend, quem lê a claim tem de ser o BFF — e é exatamente por isso que este pacote
existe em vez de o navegador falar direto com a Platform.
true só quando as duas claims estão corretas (provisioning=true e
provisioning_scope=create_initial_branch), espelhando a guarda da Platform: um escopo futuro
não pode mandar o usuário para a tela errada.
O fluxo completo
signup → e-mail → POST {prefix}/verify-email → { provisioningRequired: true }
(sessão de provisioning estabelecida pelo host)
→ POST {prefix}/onboarding/branch → AccountCompletionResult
(sessão reestabelecida, agora operacional)
→ o host navega conforme `nextStep`
nextStep é OperationalReady ou ProductContinuation — enum fechado do lado da
Platform, repassado aqui sem interpretação. Quem traduz isso em rota (/dashboard,
/onboarding/empresa, /onboarding/igreja) é o host.
Erros
Preservados, nunca virando 500 genérico. O code, o status e o correlation_id da Platform
chegam intactos ao cliente — é o correlation_id que permite cruzar o log dos dois lados.
| Situação | Resposta |
|---|---|
token.invalid_or_expired |
400, code preservado |
onboarding.not_in_provisioning / not_owner |
403, code preservado |
onboarding.branch_already_exists |
409, code preservado |
rate_limit_exceeded |
429, code preservado |
sem sessão no onboarding/branch |
401 session.missing |
| Platform indisponível | 502 platform.unavailable — nunca 500 |
forgot-password e resend-verification devolvem 202 com corpo opaco ("se existir, você
receberá"). Isso é anti-enumeration deliberado da Platform: e-mail conhecido e
desconhecido produzem resposta byte a byte idêntica, e o pacote não pode "melhorar" isso
revelando se a conta existe.
Cobrança
Decidido em ADR-021 §3.3 e §3.5. As rotas de cobrança do titular, no domínio do produto, opt-in:
app.MapOrechioAccountEndpoints();
app.MapOrechioBillingEndpoints(); // sem isto, nenhuma rota de cobrança existe
Não há AddOrechioBillingBff: configuração, cliente HTTP e validação vêm do mesmo
AddOrechioAccountBff. O prefixo é próprio — OrechioAccountBffOptions.BillingRoutePrefix,
default /api/billing, com a mesma validação do RoutePrefix.
Sessão. Cada rota lê o access token pelo mesmo IAccountSessionAccessor que o host já
registra para o onboarding/branch e o envia à Platform como Authorization: Bearer. Sem
sessão, 401 session.missing — e a Platform nem é chamada. As rotas são anônimas para o
ASP.NET e não públicas: quem autoriza é a Platform (titular do tenant, ADR-021 §3.1).
| Rota do BFF | Chama na Platform |
|---|---|
GET {prefix}/subscription |
GET /billing/subscription |
GET {prefix}/profile |
GET /billing/profile |
POST {prefix}/profile |
POST /billing/profile |
PATCH {prefix}/profile/contact |
PATCH /billing/profile/contact |
PATCH {prefix}/profile/address |
PATCH /billing/profile/address |
GET {prefix}/plans |
GET /billing/plans (sem query: o app é o do token) |
GET {prefix}/pricing/preview |
GET /billing/pricing/preview (query repassada) |
POST {prefix}/checkout |
POST /billing/subscription/checkout |
GET {prefix}/invoices |
GET /billing/invoices (query repassada) |
GET {prefix}/invoices/{id} |
GET /billing/invoices/{id} |
POST {prefix}/invoices/{id}/refresh |
POST /billing/invoices/{id}/payment-data/refresh |
Não existe PUT profile: a Platform atualiza o perfil por partes, e o BFF espelha as duas
rotas. {id} só aceita GUID — texto livre ali viraria caminho arbitrário na Platform.
Cancelar, trocar plano, reativar e trial ficam fora (ADR-021 §3.5).
Corpo. O corpo do browser segue para a Platform como veio, e o de sucesso volta ao browser
como a Platform o escreveu — sem DTO no meio, inclusive o bloco pix do checkout e do refresh.
O checkout não precisa de applicationId: sem ele, a Platform usa o app do token de produto,
como no plans.
Erros. O Commerce ainda responde erro no formato legado { "message": "x" }. O BFF o
traduz para o mesmo problem+json das rotas de conta, com code: "x" e o mesmo status — o
browser vê access.platform_only ou billing_profile.not_found, não um genérico. Erro que já
é ProblemDetails é repassado como nas rotas de conta (code, status, correlation_id), e
Platform indisponível é 502 platform.unavailable.
Papéis
Desde a 0.6.0 (ROL-5). As rotas que a tela de gestão de papéis consome, no domínio do produto, opt-in:
app.MapOrechioAccountEndpoints();
app.MapOrechioRoleEndpoints(); // sem isto, nenhuma rota de papéis existe
O prefixo é próprio — OrechioAccountBffOptions.RolesRoutePrefix, default /api/roles, com a
mesma validação do RoutePrefix. Sessão, erros e corpo funcionam como na cobrança: Bearer lido
do IAccountSessionAccessor, 401 session.missing sem sessão (e a Platform nem é chamada),
rotas anônimas para o ASP.NET e não públicas, erro legado { message } traduzido para
code, ProblemDetails repassado, 502 platform.unavailable, corpo de sucesso intacto
(isTenantCustomized, reserved…).
| Rota do BFF | Chama na Platform |
|---|---|
GET {prefix} |
GET /admin/applications/{appId}/roles |
GET {prefix}/permission-catalog |
GET /admin/applications/{appId}/permission-catalog |
POST {prefix}/{roleId}/permissions/{permissionId} |
POST /admin/roles/{roleId}/permissions/{permissionId} |
DELETE {prefix}/{roleId}/permissions/{permissionId} |
DELETE /admin/roles/{roleId}/permissions/{permissionId} |
O app vem do token. O appId das duas leituras é a claim app_id do access token da sessão,
lida sem validar assinatura (a Platform checa o mesmo app_id, ROL-4). Nenhuma query string do
browser é repassada, então o browser não escolhe de que app são os papéis. Token sem app_id
legível como GUID é 401 session.missing, sem chamada à Platform. As rotas de escrita não
precisam do app (o roleId basta). Os ids só aceitam GUID.
Quem autoriza é a Platform. Ela exige system.view_roles (leitura) e system.manage_roles
(escrita) no papel de quem chama — permissões do manifesto do produto (ADR 020). Sem elas, o erro
permission.denied da Platform chega ao browser com o code. Criar, apagar e restaurar papel
ficam fora.
Sessão (token no cookie)
Desde a 0.3.0 (ADR-022), para o modelo em que o token mora num cookie HttpOnly do produto — o
do Store e do Moveis. Opt-in: um host que não chama nada disto segue implementando
ISessionCookieWriter e IAccountSessionAccessor como antes. O modelo "token no servidor"
(o do Portal) não entra.
Composição mínima:
builder.Services.AddOrechioAccountBff(options =>
{
options.PlatformUrl = "https://api.example.com";
options.ApplicationSlug = "meu-produto";
// options.SessionRoutePrefix = "/api/auth"; // default
// options.CookieDomain = ".meuproduto.com"; // default: ausente
// options.LoginRateLimit.PermitLimit = 5; options.LoginRateLimit.Window = TimeSpan.FromMinutes(5);
// options.RefreshRateLimit.PermitLimit = 10; options.RefreshRateLimit.Window = TimeSpan.FromMinutes(5);
});
builder.Services.AddOrechioCookieSession(); // o pacote VIRA o ISessionCookieWriter e o IAccountSessionAccessor
var app = builder.Build();
app.UseOrechioCookieSession(); // antes de UseAuthentication()
app.UseAuthentication();
app.UseAuthorization();
app.MapOrechioSessionEndpoints();
app.MapOrechioAccountEndpoints(); // onboarding e cobrança leem o MESMO cookie
app.MapOrechioBillingEndpoints();
O AuthClient do @orechio/platform-angular funciona sem mudança: o contrato é o do Store.
| Rota | Chama na Platform | Devolve |
|---|---|---|
POST {prefix}/login |
auth/login (+ select-tenant, select-branch, me) |
200 sessão; 409 auth.tenant_selection_required (tenants); 409 auth.branch_selection_required (branches, branchOptions) |
POST {prefix}/refresh |
auth/refresh (+ me) |
200 sessão; 401 se a Platform recusa |
POST {prefix}/logout |
auth/logout |
204, sempre, com os cookies limpos |
GET {prefix}/session |
auth/me |
200 sessão (sem expiresAt) |
POST {prefix}/select-branch |
auth/select-branch (+ me) |
200 sessão |
Erros: sem cookie, session, select-branch e refresh respondem 401 session.missing sem
chamar a Platform; cookie presente mas inválido, expirado ou de outro app segue para a
Platform e devolve o 401 dela. Platform indisponível ou resposta inválida é 502; além do
limite, login e refresh respondem 429.
Cookies (nomes e paths já emitidos pelo Store e pelo Moveis — a sessão de quem está logado
sobrevive à migração): access_token (path /, vida = expiresIn), refresh_token (path =
SessionRoutePrefix, 30 dias), XSRF-TOKEN (legível pelo browser, Base64Url). HttpOnly nos
dois primeiros, SameSite=Lax, Secure fora de Development, domínio só se configurado.
Regras.
- Rate limit em
loginerefresh, porRemoteIpAddress(confiar emX-Forwarded-*é do host). O limitador existe sempre; os números são opções, com o default do Store. - CSRF:
UseOrechioCookieSessionexigeX-XSRF-TOKENigual ao cookie em toda mutação que vem do cookie, comparando como texto opaco em tempo constante — o XSRF em Base64 emitido antes da 0.3.0 segue válido.loginerefreshsão o bootstrap (path normalizado, sem barra final). Recusa:400 { "code": "csrf.invalid" }. - Autoridade do cookie: as rotas do pacote ignoram
Authorization. Na API do host, umAuthorizationexplícito impede a promoção do cookie e dispensa o CSRF. - Logout sempre limpa os cookies, mesmo com a Platform fora do ar ou recusando. Sucesso
significa sessão local encerrada, não refresh token revogado: a falha de revogação vai
para um log estruturado (
Outcome,UpstreamStatus,TenantId,UserId— sem token nem e-mail). - Nenhuma resposta carrega
accessToken/refreshToken.
Provisionamento da primeira filial no login (0.5.0)
O dono de um tenant que exige provisionamento e ainda não tem filial recebe da Platform um token
sem branch_id e com as claims provisioning=true + provisioning_scope=create_initial_branch.
Em vez de 502, login e refresh estabelecem o cookie e respondem 200 com
provisioningRequired: true e branchId nulo; GET {prefix}/session devolve o mesmo campo, lido do
token do cookie, para um reload não perder o desvio. Qualquer outro token sem branch_id continua 502.
O front leva à tela de primeira filial (POST /onboarding/branch, a rota de conta que já existe). Ela
usa o cookie desta mesma sessão e o troca pelo par operacional; dali em diante provisioningRequired
volta false. A sessão de provisionamento não libera nada: a Platform só aceita esse token na rota de
primeira filial. As claims são lidas sem validar assinatura, só para navegação.
Atrás de proxy
Desde a 0.4.0 (ADR-023), o pacote atesta à Platform o IP do cliente nas rotas auth/* que têm
limitador por IP. Sem isso, atrás do BFF a Platform só enxerga o IP do servidor do produto —
o mesmo para todo usuário — e o limite por IP (5 logins errados em 15 min, por exemplo) vira
global: cinco falhas de qualquer pessoa bloqueiam todas as outras.
builder.Services.AddOrechioAccountBff(options =>
{
options.PlatformUrl = "https://api.example.com";
options.ApplicationSlug = "meu-produto";
// A credencial (ApplicationCredential) da Application DESTE produto, como segredo do servidor:
options.ApplicationApiKey = builder.Configuration["Orechio:ApplicationApiKey"];
});
- O que viaja.
X-Orechio-Client-IP← oHttpContext.Connection.RemoteIpAddressdo request do browser (canonizado: IPv4 mapeado em IPv6 vira IPv4) eX-Api-Key←ApplicationApiKey, só emauth/login,refresh,select-branch,select-tenant,forgot-password,reset-password,accept-invite,verify-email,resend-verificationeonboarding/initial-branch.auth/me,auth/logout,billing/*e o resto não levam nada. - O navegador nunca escolhe.
X-Orechio-Client-IPeX-Api-Keyque o browser mandar são ignorados: o pacote monta a requisição à Platform do zero, a partir do servidor. - A credencial só afirma "este é o IP que chegou ao BFF". Não autentica usuário nem autoriza nada. A Platform só a aceita se ela for ativa e da Application da operação; em qualquer outro caso usa o IP que enxerga, sem erro. Uma revogação leva até 60 s para valer para isto.
- Sem
ApplicationApiKey(vazia ou ausente): nenhum cabeçalho é enviado e o limite por IP da Platform fica global para o produto, como antes da 0.4.0. O pacote registra um aviso na subida dizendo isso — nunca o valor da credencial, que não aparece em log algum.
O host precisa informar o IP certo ao pacote
O IP atestado é o RemoteIpAddress do request. Atrás de um proxy ou edge (nginx, Cloudflare,
Railway, um balanceador), esse valor é o do proxy, a menos que o host o reescreva com o IP do
cliente. Configure ForwardedHeaders (ou equivalente) no host; senão a atestação informa o IP
do edge e o limite continua global na prática, só que agora sem aviso:
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
o.ForwardLimit = 1; // quantos proxies confiáveis existem na frente
// o.KnownProxies.Add(IPAddress.Parse("...")); // restrinja a quem pode informar X-Forwarded-For
});
var app = builder.Build();
app.UseForwardedHeaders(); // ANTES de qualquer coisa que leia RemoteIpAddress
Cuidado: confiar em X-Forwarded-For de qualquer origem deixa o cliente escolher o próprio IP — e
com ele a chave do limitador. Confiar no edge é decisão do host (ADR-022 §2.3); o pacote só
repassa o que o host entrega em RemoteIpAddress.
O que este pacote deliberadamente NÃO faz
- Não conhece vertical. Nada de
StoreProfile,CongregationProfile, fiscal, igreja, marcenaria. A fronteira do core termina em sessão operacional; o produto continua por contrato (nextStep), nunca por o pacote conhecer o domínio dele. Travado por teste. - Não decide navegação. Isso é do host.
- Não impõe modelo de sessão.
login/refresh/logout/sessionsó existem para quem liga a "Sessão (token no cookie)" acima; o modelo "token no servidor" (Portal) segue sendo do host, e o pacote não ganha abstração para "onde a sessão vive" além das duas interfaces. - Não emite nem valida XSRF fora do cookie canônico. Só
AddOrechioCookieSession/UseOrechioCookieSessiono fazem; quem não os chama continua com o XSRF do host. - Não guarda estado.
- Nunca devolve token ao browser. Os tokens vão para
Establishe param ali. Há guard de teste varrendo todo payload poraccessToken,refreshTokeneeyJ.
Dependência
Orechio.Platform.Client por PackageReference, versão fixa (1.5.0) — nunca floating.
O que se usa dele é concreto: OrechioProblemDetails (+ TryParse), a forma canônica de erro
da Platform, reusada em vez de um segundo parser que divergiria na primeira mudança.
Zero ProjectReference para internals da Platform — o pacote é distribuível e precisa
funcionar fora do monorepo. Provado por tests/PackageSmoke.
Versionamento
Sob o ADR-010 §10 e a clarificação normativa do PACKAGES-DOC-1: release é a versão publicada
no registry público (nuget.org). Empacotar em feed local é empacotamento, nunca release.
Entradas de CHANGELOG para versões empacotadas e não publicadas permanecem, retituladas
(não publicado — feed local; incorporado em X.Y.Z).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Orechio.Platform.Client (>= 1.5.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.