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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Orechio.Platform.Bff.Account" Version="0.6.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Orechio.Platform.Bff.Account" Version="0.6.0" />
                    
Directory.Packages.props
<PackageReference Include="Orechio.Platform.Bff.Account" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Orechio.Platform.Bff.Account --version 0.6.0
                    
#r "nuget: Orechio.Platform.Bff.Account, 0.6.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Orechio.Platform.Bff.Account@0.6.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Orechio.Platform.Bff.Account&version=0.6.0
                    
Install as a Cake Addin
#tool nuget:?package=Orechio.Platform.Bff.Account&version=0.6.0
                    
Install as a Cake Tool

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.

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 login e refresh, por RemoteIpAddress (confiar em X-Forwarded-* é do host). O limitador existe sempre; os números são opções, com o default do Store.
  • CSRF: UseOrechioCookieSession exige X-XSRF-TOKEN igual 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. login e refresh sã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, um Authorization explí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 ← o HttpContext.Connection.RemoteIpAddress do request do browser (canonizado: IPv4 mapeado em IPv6 vira IPv4) e X-Api-Key ← ApplicationApiKey, só em auth/login, refresh, select-branch, select-tenant, forgot-password, reset-password, accept-invite, verify-email, resend-verification e onboarding/initial-branch. auth/me, auth/logout, billing/* e o resto não levam nada.
  • O navegador nunca escolhe. X-Orechio-Client-IP e X-Api-Key que 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/session só 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 / UseOrechioCookieSession o 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 Establish e param ali. Há guard de teste varrendo todo payload por accessToken, refreshToken e eyJ.

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.6.0 95 11/8/2026
0.5.0 719 10/23/2026
0.4.0 791 10/1/2026
0.3.0 104 9/30/2026
0.2.0 128 9/30/2026
0.1.2 143 9/18/2026
0.1.1 405 9/17/2026
0.1.0 99 10/23/2026