Introdução

Se você já implementou autenticação em uma SPA (Single Page Application) usando tokens diretamente no navegador, provavelmente conviveu com uma preocupação constante: onde armazenar o access token de forma segura? O localStorage é vulnerável a ataques XSS (Cross-Site Scripting), o sessionStorage tem as mesmas fragilidades, e até cookies acessíveis via JavaScript podem ser comprometidos. É exatamente esse problema que o padrão BFF — Backend For Frontend resolve.

O BFF (Backend For Frontend) é um padrão arquitetural onde um servidor backend intermediário atua como proxy de autenticação entre a SPA e o provedor de identidade (como o Azure Entra ID). Em vez da SPA lidar diretamente com tokens OAuth2, o BFF recebe os tokens, armazena-os de forma segura no servidor e estabelece uma sessão com o navegador por meio de cookies HttpOnly — que são inacessíveis ao JavaScript, eliminando vetores de ataque XSS sobre credenciais.

Neste artigo, você vai entender por que o BFF se tornou a recomendação oficial da IETF para SPAs, como a arquitetura funciona na prática e como implementá-la do zero usando Angular 16+ no front-end e ASP.NET Core 8 no backend com Azure Entra ID. Se você ainda não está familiarizado com os fundamentos de autenticação e os tipos de Client ID, recomendo antes a leitura de Autenticação e Autorização: JWT, OAuth2 e OpenID Connect, onde explicamos JWT, OAuth2 e OpenID Connect em detalhes.


Por que SPAs Não Devem Gerenciar Tokens Diretamente?

Antes de entender o BFF, é importante compreender por que o modelo tradicional de tokens no navegador é arriscado. Em uma SPA convencional com OAuth2, o fluxo funciona assim:

  1. A SPA redireciona o usuário ao provedor de identidade.
  2. Após login, a SPA recebe tokens (access token, ID token, refresh token).
  3. A SPA armazena os tokens no navegador e os envia nas requisições à API.

O problema está no passo 3. Qualquer local de armazenamento acessível ao JavaScript é vulnerável:

Local de armazenamentoVulnerável a XSS?Vulnerável a CSRF?Observação
localStorageSimNãoQualquer script malicioso pode ler tokens
sessionStorageSimNãoMesma vulnerabilidade, escopo por aba
Cookie (sem HttpOnly)SimSimPior cenário — vulnerável a ambos
Cookie HttpOnlyNãoSim (mitigável)Não acessível ao JS + proteção CSRF possível

⚠️ Atenção: Um único ataque XSS bem-sucedido em uma SPA que armazena tokens no localStorage pode comprometer todas as credenciais do usuário, incluindo refresh tokens que concedem acesso prolongado. Diferente de um cookie de sessão roubado (que expira), um refresh token pode ser usado por dias ou semanas.

A recomendação da IETF para OAuth 2.0 em aplicações baseadas em navegador é clara: SPAs devem utilizar um componente backend (BFF) para gerenciar tokens, em vez de manipulá-los diretamente no navegador.

Os Riscos Concretos

  • XSS (Cross-Site Scripting): se um atacante conseguir injetar JavaScript na página (via dependência comprometida, input não sanitizado, etc.), ele pode ler tokens do localStorage/sessionStorage e exfiltrá-los para um servidor externo.
  • Supply chain attacks: bibliotecas npm/yarn comprometidas podem incluir código malicioso que lê tokens do storage do navegador.
  • Token replay: tokens capturados podem ser usados em qualquer lugar, pois não estão vinculados a um navegador ou IP específico.

O que é o Padrão BFF (Backend For Frontend)?

O BFF (Backend For Frontend) é um padrão arquitetural onde um servidor backend dedicado atua como intermediário entre o front-end (SPA) e os serviços de identidade/APIs protegidas. O conceito foi popularizado por Sam Newman e adaptado especificamente para segurança em SPAs pela comunidade OAuth.

Como Funciona a Arquitetura BFF

Diagrama da arquitetura BFF mostrando o fluxo de comunicação entre Angular SPA, BFF Server, APIs protegidas e Azure Entra ID

Fluxo Passo a Passo

Diagrama de sequência mostrando os 11 passos do fluxo de autenticação BFF entre Angular SPA, BFF Server e Azure Entra ID

  1. O usuário acessa a SPA Angular no navegador.
  2. A SPA verifica se há sessão ativa chamando GET /bff/user no BFF.
  3. Se não autenticado, a SPA redireciona o navegador para GET /bff/login.
  4. O BFF inicia o fluxo OAuth 2.0 Authorization Code (com Client Secret) contra o Azure Entra ID.
  5. O usuário se autentica no Azure Entra ID e consente o acesso.
  6. O Azure Entra ID retorna o authorization code ao BFF (callback).
  7. O BFF troca o code por access token, ID token e refresh token.
  8. O BFF armazena os tokens na sessão do servidor (memória, Redis, banco, etc.).
  9. O BFF emite um cookie de sessão HttpOnly, Secure, SameSite=Strict para o navegador.
  10. A SPA faz requisições às APIs via BFF (/api/*), enviando automaticamente o cookie.
  11. O BFF lê a sessão, recupera o access token e faz proxy da requisição para a API real, incluindo o token no header Authorization: Bearer.

ℹ️ Informação: Note que a SPA nunca vê nem manipula tokens. Toda a gestão de credenciais fica no servidor BFF, que é um ambiente controlado e seguro — um Confidential Client no OAuth 2.0.

Vantagens do BFF

AspectoSPA Tradicional (tokens no browser)BFF (tokens no servidor)
Tokens no navegadorSim (localStorage/sessionStorage)Não — ficam no servidor
Vulnerável a XSSSim — tokens podem ser roubadosNão — cookie HttpOnly inacessível
Tipo de Client OAuthPublic ClientConfidential Client (mais seguro)
Client SecretNão pode usarSim — seguro no servidor
Refresh tokensNo navegador (risco)No servidor (seguro)
CSRFNão se aplicaMitigável com SameSite + tokens
ComplexidadeMenor (tudo no front)Maior (servidor adicional)
Recomendação IETFNão recomendado para novos projetosRecomendado

Implementação do BFF com ASP.NET Core 8

Vamos construir o BFF passo a passo. O projeto ASP.NET Core atuará simultaneamente como servidor de autenticação (OpenID Connect), gerenciador de sessão e proxy reverso para as APIs protegidas.

Estrutura do Projeto

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
MinhaSPA.Bff/
├── Program.cs                    # Configuração principal do BFF
├── appsettings.json             # Configurações do Azure Entra ID e proxy
├── Endpoints/
│   └── BffEndpoints.cs          # Endpoints /bff/login, /bff/logout, /bff/user
├── Properties/
│   └── launchSettings.json
├── MinhaSPA.Bff.csproj
nginx/
├── nginx.conf                    # Configuração do NGINX como reverse proxy
└── Dockerfile                    # Container NGINX para produção

Criação do Projeto e Dependências

1
2
3
4
5
6
# Criar projeto ASP.NET Core 8 (vazio — sem template MVC/API)
dotnet new web -n MinhaSPA.Bff --framework net8.0
cd MinhaSPA.Bff

# Pacotes de autenticação OpenID Connect
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

ℹ️ Informação: Nesta arquitetura, o NGINX atua como reverse proxy na frente do BFF, servindo os arquivos estáticos do Angular e encaminhando as requisições /bff/* e /api/* para o ASP.NET Core. O BFF, por sua vez, faz o proxy das chamadas /api/* para as APIs protegidas via HttpClient, injetando o access token.

Configuração (appsettings.json)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "seu-tenant-id",
    "ClientId": "seu-client-id-do-bff",
    "ClientSecret": "seu-client-secret",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath": "/signout-callback-oidc"
  },
  "ApiProxy": {
    "BaseUrl": "https://api.seudominio.com",
    "TimeoutSeconds": 30
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

Configuração Principal (Program.cs)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
// Program.cs — BFF com autenticação OpenID Connect, sessão HttpOnly e proxy via HttpClient
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

// ===== 1. SESSÃO E COOKIES =====
// Configurar cookie de sessão HttpOnly, Secure, SameSite
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
    options.Cookie.Name = "MinhaSPA.Session";
    options.Cookie.HttpOnly = true;       // JavaScript NÃO pode acessar este cookie
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;  // Apenas HTTPS
    options.Cookie.SameSite = SameSiteMode.Strict;            // Proteção CSRF
    options.ExpireTimeSpan = TimeSpan.FromHours(8);           // Sessão de 8 horas
    options.SlidingExpiration = true;                          // Renovação automática

    // Retornar 401 em vez de redirecionar para login em chamadas AJAX
    options.Events.OnRedirectToLogin = context =>
    {
        if (context.Request.Path.StartsWithSegments("/api") ||
            context.Request.Path.StartsWithSegments("/bff"))
        {
            context.Response.StatusCode = 401;
            return Task.CompletedTask;
        }
        context.Response.Redirect(context.RedirectUri);
        return Task.CompletedTask;
    };
})
// ===== 2. OPENID CONNECT COM AZURE ENTRA ID =====
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    var azureAd = builder.Configuration.GetSection("AzureAd");

    options.Authority = $"{azureAd["Instance"]}{azureAd["TenantId"]}/v2.0";
    options.ClientId = azureAd["ClientId"];
    options.ClientSecret = azureAd["ClientSecret"];  // Confidential Client — seguro!
    options.CallbackPath = azureAd["CallbackPath"];
    options.SignedOutCallbackPath = azureAd["SignedOutCallbackPath"];

    options.ResponseType = OpenIdConnectResponseType.Code;         // Authorization Code Flow
    options.SaveTokens = true;               // Salvar tokens na sessão (server-side!)
    options.GetClaimsFromUserInfoEndpoint = true;
    options.MapInboundClaims = false;        // Manter claims originais do Azure Entra

    // Escopos: openid + profile + email + escopos da sua API
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.Scope.Add("offline_access");     // Refresh token para renovação silenciosa
    options.Scope.Add("api://sua-api-client-id/User.Read");

    // Evento: renovar access token expirado usando refresh token
    options.Events = new OpenIdConnectEvents
    {
        OnTokenValidated = async context =>
        {
            // Aqui você pode adicionar claims customizadas, carregar roles do banco, etc.
            var nome = context.Principal?.FindFirst("name")?.Value;
            Console.WriteLine($"Usuário autenticado: {nome}");
        }
    };
});

builder.Services.AddAuthorization();

// ===== 3. HTTPCLIENT PARA PROXY DAS APIS =====
// HttpClient nomeado para fazer proxy das requisições /api/* para a API real
var apiBaseUrl = builder.Configuration["ApiProxy:BaseUrl"] ?? "https://api.seudominio.com";
var timeoutSeconds = builder.Configuration.GetValue<int>("ApiProxy:TimeoutSeconds", 30);

builder.Services.AddHttpClient("ApiProxy", client =>
{
    client.BaseAddress = new Uri(apiBaseUrl);
    client.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
    client.DefaultRequestHeaders.Accept.Add(
        new MediaTypeWithQualityHeaderValue("application/json"));
});

// ===== 4. CORS (para desenvolvimento local) =====
builder.Services.AddCors(options =>
{
    options.AddPolicy("PermitirAngular", corsBuilder =>
    {
        corsBuilder
            .WithOrigins("http://localhost:4200")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();  // Necessário para cookies cross-origin em dev
    });
});

// ===== 5. ANTIFORGERY (proteção CSRF) =====
builder.Services.AddAntiforgery(options =>
{
    options.HeaderName = "X-XSRF-TOKEN";   // Header que o Angular enviará
    options.Cookie.Name = "XSRF-TOKEN";    // Cookie que o Angular lerá
    options.Cookie.HttpOnly = false;        // Angular precisa ler este cookie (apenas o CSRF)
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

var app = builder.Build();

app.UseHttpsRedirection();
app.UseCors("PermitirAngular");
app.UseAuthentication();
app.UseAuthorization();

// ===== 6. ENDPOINTS DO BFF =====
// Endpoint: verificar estado de autenticação do usuário
app.MapGet("/bff/user", (HttpContext ctx) =>
{
    if (ctx.User.Identity?.IsAuthenticated != true)
    {
        return Results.Unauthorized();
    }

    // Retornar claims do usuário (do ID Token, armazenado na sessão)
    var claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }).ToList();
    return Results.Ok(new
    {
        IsAuthenticated = true,
        Nome = ctx.User.FindFirst("name")?.Value,
        Email = ctx.User.FindFirst("email")?.Value,
        Claims = claims
    });
}).RequireAuthorization();

// Endpoint: iniciar login via OpenID Connect
app.MapGet("/bff/login", (HttpContext ctx, string? returnUrl) =>
{
    var redirectUri = returnUrl ?? "/";
    return Results.Challenge(
        new AuthenticationProperties { RedirectUri = redirectUri },
        [OpenIdConnectDefaults.AuthenticationScheme]
    );
});

// Endpoint: logout — limpa sessão local e redireciona ao Azure Entra
app.MapGet("/bff/logout", async (HttpContext ctx) =>
{
    await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
        new AuthenticationProperties { RedirectUri = "/" });
});

// ===== 7. PROXY PARA APIs — /api/* são proxied para a API real com access token =====
app.MapMethods("/api/{**remainder}", new[] { "GET", "POST", "PUT", "DELETE", "PATCH" },
    async (HttpContext ctx, IHttpClientFactory httpClientFactory, string remainder) =>
{
    // Recuperar o access token da sessão do usuário
    var accessToken = await ctx.GetTokenAsync("access_token");
    if (string.IsNullOrEmpty(accessToken))
    {
        return Results.Unauthorized();
    }

    // Criar requisição para a API real
    var client = httpClientFactory.CreateClient("ApiProxy");
    var targetPath = $"/{remainder}{ctx.Request.QueryString}";

    using var requestMessage = new HttpRequestMessage(
        new HttpMethod(ctx.Request.Method), targetPath);

    // Injetar o access token no header Authorization
    requestMessage.Headers.Authorization =
        new AuthenticationHeaderValue("Bearer", accessToken);

    // Copiar o body para requisições com corpo (POST, PUT, PATCH)
    if (ctx.Request.ContentLength > 0 || ctx.Request.ContentType != null)
    {
        requestMessage.Content = new StreamContent(ctx.Request.Body);
        if (ctx.Request.ContentType != null)
        {
            requestMessage.Content.Headers.ContentType =
                System.Net.Http.Headers.MediaTypeHeaderValue.Parse(ctx.Request.ContentType);
        }
    }

    // Encaminhar headers relevantes
    foreach (var header in new[] { "Accept", "Accept-Language", "X-Request-ID" })
    {
        if (ctx.Request.Headers.TryGetValue(header, out var value))
        {
            requestMessage.Headers.TryAddWithoutValidation(header, value.ToString());
        }
    }

    // Enviar requisição à API real
    var response = await client.SendAsync(requestMessage);

    // Retornar a resposta da API para o Angular
    ctx.Response.StatusCode = (int)response.StatusCode;
    ctx.Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json";
    await response.Content.CopyToAsync(ctx.Response.Body);

}).RequireAuthorization();

// ===== 8. FALLBACK (apenas em desenvolvimento sem NGINX) =====
if (app.Environment.IsDevelopment())
{
    app.UseDefaultFiles();
    app.UseStaticFiles();
    app.MapFallbackToFile("index.html");
}

app.Run();

💡 Dica: O SaveTokens = true faz com que os tokens sejam armazenados no cookie de sessão criptografado pelo ASP.NET Core Data Protection. Em produção, para evitar cookies muito grandes, considere usar armazenamento de sessão distribuído com Redis ou SQL Server.


Configuração do NGINX como Reverse Proxy

Em produção, o NGINX fica na frente de toda a arquitetura. Ele é responsável por servir os arquivos estáticos do Angular (eliminando essa responsabilidade do ASP.NET Core), encaminhar requisições /bff/* e /api/* para o BFF, gerenciar TLS/SSL e aplicar rate limiting e caching de forma eficiente.

Diagrama da Arquitetura com NGINX

Diagrama da arquitetura com NGINX como reverse proxy mostrando o fluxo entre Navegador, NGINX, arquivos estáticos, BFF ASP.NET Core e APIs protegidas

Configuração do NGINX (nginx/nginx.conf)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# nginx.conf — Reverse proxy para BFF + SPA Angular
worker_processes auto;
events {
    worker_connections 1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile      on;
    keepalive_timeout 65;

    # ===== RATE LIMITING =====
    # Proteção contra abuso — limitar requisições por IP
    limit_req_zone $binary_remote_addr zone=bff_limit:10m rate=30r/s;
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/s;

    # ===== GZIP =====
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;
    gzip_min_length 256;

    # ===== UPSTREAM — BFF ASP.NET Core =====
    upstream bff_backend {
        server bff:5000;          # Nome do container Docker ou IP do BFF
        keepalive 32;             # Conexões persistentes para melhor performance
    }

    server {
        listen 80;
        server_name app.seudominio.com;

        # Redirecionar HTTP para HTTPS
        return 301 https://$server_name$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name app.seudominio.com;

        # ===== TLS/SSL =====
        ssl_certificate     /etc/nginx/ssl/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/privkey.pem;
        ssl_protocols       TLSv1.2 TLSv1.3;
        ssl_ciphers         HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;

        # ===== SECURITY HEADERS =====
        add_header X-Frame-Options "DENY" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://login.microsoftonline.com" always;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

        # ===== ROTAS /bff/* — Proxy para o BFF (autenticação) =====
        location /bff/ {
            limit_req zone=bff_limit burst=10 nodelay;

            proxy_pass http://bff_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            # Importante: encaminhar cookies para o BFF
            proxy_pass_request_headers on;
            proxy_cookie_path / /;
        }

        # ===== ROTAS /api/* — Proxy para o BFF (que faz proxy para a API real) =====
        location /api/ {
            limit_req zone=api_limit burst=20 nodelay;

            proxy_pass http://bff_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            # Encaminhar cookies (sessão do BFF)
            proxy_pass_request_headers on;
            proxy_cookie_path / /;

            # Timeouts para evitar bloqueio em APIs lentas
            proxy_connect_timeout 10s;
            proxy_send_timeout    30s;
            proxy_read_timeout    30s;
        }

        # ===== CALLBACKS DO OPENID CONNECT =====
        location /signin-oidc {
            proxy_pass http://bff_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_pass_request_headers on;
        }

        location /signout-callback-oidc {
            proxy_pass http://bff_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_pass_request_headers on;
        }

        # ===== ANGULAR SPA — Arquivos estáticos =====
        location / {
            root /usr/share/nginx/html;   # Build do Angular (ng build)
            index index.html;
            try_files $uri $uri/ /index.html;  # SPA fallback — Angular routing

            # Cache agressivo para assets estáticos
            location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
                expires 1y;
                add_header Cache-Control "public, immutable";
            }
        }
    }
}

Docker Compose para NGINX + BFF

Para facilitar o deploy, utilize Docker Compose para orquestrar o NGINX e o BFF:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# docker-compose.yml — NGINX + BFF ASP.NET Core
version: "3.8"

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./dist/minha-spa:/usr/share/nginx/html:ro   # Build do Angular
    depends_on:
      - bff
    restart: unless-stopped

  bff:
    build:
      context: ./MinhaSPA.Bff
      dockerfile: Dockerfile
    environment:
      - ASPNETCORE_URLS=http://+:5000
      - AzureAd__TenantId=${AZURE_TENANT_ID}
      - AzureAd__ClientId=${AZURE_CLIENT_ID}
      - AzureAd__ClientSecret=${AZURE_CLIENT_SECRET}
    expose:
      - "5000"    # Apenas acessível internamente pelo NGINX
    restart: unless-stopped

💡 Dica: Em desenvolvimento local, você pode usar o ng serve do Angular com proxy para o BFF (via proxy.conf.json) sem precisar do NGINX. O NGINX entra em cena apenas em ambientes de staging e produção, onde performance, TLS e security headers são essenciais.


Renovação Automática de Tokens no BFF

Um aspecto crítico do BFF é a renovação silenciosa de access tokens usando o refresh token. O access token tem vida curta (tipicamente 1 hora no Azure Entra ID), mas o refresh token permite obter novos tokens sem que o usuário precise fazer login novamente.

Adicione este middleware ao Program.cs para renovar tokens automaticamente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// Middleware de renovação automática de tokens — adicionar ANTES de UseAuthorization()
app.Use(async (context, next) =>
{
    if (context.User.Identity?.IsAuthenticated == true)
    {
        var expiresAt = await context.GetTokenAsync("expires_at");

        if (DateTimeOffset.TryParse(expiresAt, out var expiracao) &&
            expiracao < DateTimeOffset.UtcNow.AddMinutes(5)) // Renovar 5 min antes de expirar
        {
            var refreshToken = await context.GetTokenAsync("refresh_token");

            if (!string.IsNullOrEmpty(refreshToken))
            {
                try
                {
                    // Montar requisição de renovação ao Azure Entra ID
                    var config = context.RequestServices
                        .GetRequiredService<IConfiguration>();
                    var azureAd = config.GetSection("AzureAd");

                    using var httpClient = new HttpClient();
                    var tokenEndpoint = $"{azureAd["Instance"]}{azureAd["TenantId"]}/oauth2/v2.0/token";

                    var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
                    {
                        ["grant_type"] = "refresh_token",
                        ["client_id"] = azureAd["ClientId"]!,
                        ["client_secret"] = azureAd["ClientSecret"]!,
                        ["refresh_token"] = refreshToken,
                        ["scope"] = "openid profile email offline_access api://sua-api-client-id/User.Read"
                    });

                    var response = await httpClient.PostAsync(tokenEndpoint, tokenRequest);

                    if (response.IsSuccessStatusCode)
                    {
                        var payload = await response.Content
                            .ReadFromJsonAsync<Dictionary<string, object>>();

                        // Atualizar tokens na sessão
                        var authInfo = await context.AuthenticateAsync();
                        authInfo.Properties?.UpdateTokenValue(
                            "access_token", payload!["access_token"].ToString()!);
                        authInfo.Properties?.UpdateTokenValue(
                            "refresh_token", payload["refresh_token"].ToString()!);
                        authInfo.Properties?.UpdateTokenValue(
                            "expires_at",
                            DateTimeOffset.UtcNow
                                .AddSeconds(Convert.ToDouble(payload["expires_in"]))
                                .ToString("o"));

                        // Re-emitir o cookie de sessão com os novos tokens
                        await context.SignInAsync(
                            CookieAuthenticationDefaults.AuthenticationScheme,
                            context.User,
                            authInfo.Properties);
                    }
                }
                catch (Exception ex)
                {
                    // Log do erro — não interromper a requisição
                    var logger = context.RequestServices
                        .GetRequiredService<ILogger<Program>>();
                    logger.LogWarning(ex, "Falha ao renovar access token");
                }
            }
        }
    }

    await next(context);
});

⚠️ Atenção: Em aplicações de produção com múltiplas instâncias, a renovação de tokens precisa de cuidado especial para evitar race conditions (múltiplas instâncias renovando simultaneamente). Use um lock distribuído (Redis, por exemplo) ou delegue a renovação para um único worker.


Implementação do Angular 16+ com BFF

A grande vantagem do BFF para o front-end é a simplicidade: a SPA Angular não precisa de nenhuma biblioteca de autenticação (como MSAL). Ela apenas faz requisições HTTP normais e o cookie de sessão é enviado automaticamente pelo navegador.

Serviço de Autenticação (auth.service.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// src/app/auth/auth.service.ts — Serviço de autenticação via BFF (sem MSAL!)
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { BehaviorSubject, Observable, catchError, of, tap } from "rxjs";

// Interface que representa o usuário autenticado
export interface UsuarioBff {
  isAuthenticated: boolean;
  nome: string;
  email: string;
  claims: { type: string; value: string }[];
}

@Injectable({ providedIn: "root" })
export class AuthService {
  private http = inject(HttpClient);

  // Estado reativo do usuário
  private usuarioSubject = new BehaviorSubject<UsuarioBff | null>(null);
  public usuario$ = this.usuarioSubject.asObservable();

  /**
   * Verifica se o usuário está autenticado consultando o BFF.
   * O cookie HttpOnly é enviado automaticamente pelo navegador.
   */
  verificarAutenticacao(): Observable<UsuarioBff | null> {
    return this.http
      .get<UsuarioBff>("/bff/user", { withCredentials: true })
      .pipe(
        tap((usuario) => this.usuarioSubject.next(usuario)),
        catchError(() => {
          // 401 = não autenticado — comportamento esperado
          this.usuarioSubject.next(null);
          return of(null);
        })
      );
  }

  /**
   * Redireciona o navegador para o endpoint de login do BFF.
   * O BFF iniciará o fluxo OAuth2 Authorization Code com Azure Entra ID.
   */
  login(returnUrl: string = "/"): void {
    window.location.href = `/bff/login?returnUrl=${encodeURIComponent(returnUrl)}`;
  }

  /**
   * Redireciona para o endpoint de logout do BFF.
   * O BFF limpa a sessão local e redireciona ao Azure Entra para logout global.
   */
  logout(): void {
    window.location.href = "/bff/logout";
  }

  /** Retorna true se o usuário está autenticado. */
  get isAutenticado(): boolean {
    return this.usuarioSubject.value?.isAuthenticated === true;
  }

  /** Retorna o nome do usuário autenticado. */
  get nomeUsuario(): string {
    return this.usuarioSubject.value?.nome ?? "";
  }
}

Guard de Autenticação (auth.guard.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/app/auth/auth.guard.ts — Guard para proteger rotas (Angular 16+ functional guard)
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { AuthService } from "./auth.service";
import { map, take } from "rxjs";

export const authGuard: CanActivateFn = () => {
  const authService = inject(AuthService);
  const router = inject(Router);

  return authService.verificarAutenticacao().pipe(
    take(1),
    map((usuario) => {
      if (usuario?.isAuthenticated) {
        return true;
      }
      // Redirecionar para login no BFF, passando a URL atual como returnUrl
      authService.login(window.location.pathname);
      return false;
    })
  );
};

Interceptor CSRF (csrf.interceptor.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/app/auth/csrf.interceptor.ts — Interceptor que envia o token CSRF ao BFF
import { HttpInterceptorFn } from "@angular/common/http";

/**
 * Interceptor funcional (Angular 16+) que lê o cookie XSRF-TOKEN
 * e o envia no header X-XSRF-TOKEN em requisições mutáveis (POST, PUT, DELETE).
 *
 * O Angular HttpClient com withXsrfConfiguration já faz isso automaticamente
 * para requisições same-origin, mas este interceptor garante o comportamento
 * explícito e funciona com configurações customizadas.
 */
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
  // Apenas requisições que modificam dados precisam do token CSRF
  const metodosMutaveis = ["POST", "PUT", "DELETE", "PATCH"];

  if (metodosMutaveis.includes(req.method.toUpperCase())) {
    // Ler o cookie XSRF-TOKEN (definido pelo BFF com HttpOnly=false)
    const csrfToken = obterCookie("XSRF-TOKEN");

    if (csrfToken) {
      req = req.clone({
        setHeaders: { "X-XSRF-TOKEN": csrfToken },
      });
    }
  }

  return next(req);
};

/** Utilitário para ler um cookie pelo nome. */
function obterCookie(nome: string): string | null {
  const match = document.cookie.match(new RegExp(`(^| )${nome}=([^;]+)`));
  return match ? decodeURIComponent(match[2]) : null;
}

Configuração da Aplicação (app.config.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app/app.config.ts — Configuração standalone do Angular 16+ com BFF
import { ApplicationConfig } from "@angular/core";
import { provideRouter } from "@angular/router";
import {
  provideHttpClient,
  withInterceptors,
  withXsrfConfiguration,
} from "@angular/common/http";
import { routes } from "./app.routes";
import { csrfInterceptor } from "./auth/csrf.interceptor";

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(
      // Configuração XSRF nativa do Angular (redundância de segurança)
      withXsrfConfiguration({
        cookieName: "XSRF-TOKEN",
        headerName: "X-XSRF-TOKEN",
      }),
      // Interceptor CSRF customizado para controle explícito
      withInterceptors([csrfInterceptor])
    ),
  ],
};

Rotas Protegidas (app.routes.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/app/app.routes.ts — Rotas com guard de autenticação via BFF
import { Routes } from "@angular/router";
import { authGuard } from "./auth/auth.guard";

export const routes: Routes = [
  {
    path: "",
    loadComponent: () =>
      import("./home/home.component").then((m) => m.HomeComponent),
  },
  {
    path: "dashboard",
    loadComponent: () =>
      import("./dashboard/dashboard.component").then((m) => m.DashboardComponent),
    canActivate: [authGuard], // Protegida — requer autenticação via BFF
  },
  {
    path: "admin",
    loadComponent: () =>
      import("./admin/admin.component").then((m) => m.AdminComponent),
    canActivate: [authGuard],
  },
];

Componente com Consumo da API (dashboard.component.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// src/app/dashboard/dashboard.component.ts — Consumo de API protegida via BFF proxy
import { Component, OnInit, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HttpClient } from "@angular/common/http";
import { AuthService } from "../auth/auth.service";

@Component({
  selector: "app-dashboard",
  standalone: true,
  imports: [CommonModule],
  template: `
    <h2>Dashboard</h2>
    <p>Olá, {{ auth.nomeUsuario }}!</p>

    <button (click)="carregarDados()">Carregar Dados da API</button>
    <button (click)="auth.logout()">Logout</button>

    <div *ngIf="dados">
      <h3>Resposta da API:</h3>
      <pre>{{ dados | json }}</pre>
    </div>

    <div *ngIf="erro" class="erro">
      <p>Erro: {{ erro }}</p>
    </div>
  `,
})
export class DashboardComponent implements OnInit {
  auth = inject(AuthService);
  private http = inject(HttpClient);

  dados: any = null;
  erro: string | null = null;

  ngOnInit(): void {
    this.carregarDados();
  }

  carregarDados(): void {
    // A requisição vai para /api/dados no BFF (mesmo domínio)
    // O BFF faz proxy para a API real, injetando o access token
    // O cookie de sessão é enviado automaticamente pelo navegador
    this.http
      .get("/api/dados", { withCredentials: true })
      .subscribe({
        next: (data) => {
          this.dados = data;
          this.erro = null;
        },
        error: (err) => {
          this.erro = `Falha ao carregar dados: ${err.status} ${err.statusText}`;
          if (err.status === 401) {
            this.auth.login(window.location.pathname); // Sessão expirou
          }
        },
      });
  }
}

ℹ️ Informação: Repare que o Angular não usa nenhuma biblioteca de autenticação (sem MSAL, sem angular-oauth2-oidc). As requisições são HTTP puro com HttpClient. O cookie de sessão é enviado automaticamente pelo navegador, e o BFF cuida de todo o fluxo OAuth2 nos bastidores.


BFF vs. SPA Tradicional: Comparação Detalhada

Para entender quando utilizar cada abordagem, veja esta comparação lado a lado:

AspectoSPA Tradicional (MSAL no browser)SPA com BFF (tokens no servidor)
Biblioteca no front-end@azure/msal-angularNenhuma — apenas HttpClient
Onde ficam os tokenslocalStorage / sessionStorageSessão no servidor (memória, Redis)
Tipo de cookieNão usa (ou cookie JS-accessible)HttpOnly + Secure + SameSite=Strict
OAuth Client TypePublic Client (sem secret)Confidential Client (com secret)
Fluxo OAuthAuth Code + PKCE (no browser)Auth Code + Secret (no servidor)
Vulnerável a XSSSim — tokens acessíveis ao JSNão — tokens inacessíveis ao JS
Complexidade do front-endMaior (config MSAL, interceptors, guards)Menor (HTTP puro, sem config de auth)
Complexidade do back-endMenor (API apenas valida tokens)Maior (BFF gerencia sessão, proxy, renovação)
Renovação de tokensMSAL faz silenciosamente via iframeBFF renova no servidor via refresh token
Adequado paraMVPs, apps internas, baixo riscoProdução, apps públicas, dados sensíveis
Recomendação IETFAceitável com PKCERecomendado para novos projetos

Proteção Contra CSRF no BFF

Como o BFF usa cookies para autenticação, ele é potencialmente vulnerável a CSRF (Cross-Site Request Forgery). Um site malicioso poderia tentar enviar requisições ao BFF usando os cookies da vítima. Para mitigar isso, utilizamos três camadas de proteção:

1
2
// Já configurado no Program.cs
options.Cookie.SameSite = SameSiteMode.Strict;

O atributo SameSite=Strict instrui o navegador a não enviar o cookie em requisições originadas de outros domínios. Isso bloqueia a maioria dos ataques CSRF em navegadores modernos.

O ASP.NET Core Antiforgery emite um cookie XSRF-TOKEN legível pelo JavaScript e espera que o Angular envie o valor no header X-XSRF-TOKEN. Um site atacante não consegue ler cookies de outro domínio, portanto não pode fabricar esse header.

3. Validação de Origin/Referer

Em cenários de alta segurança, adicione validação do header Origin ou Referer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Middleware de validação de origin — adicionar antes dos endpoints
app.Use(async (context, next) =>
{
    var method = context.Request.Method;
    var metodosMutaveis = new[] { "POST", "PUT", "DELETE", "PATCH" };

    if (metodosMutaveis.Contains(method, StringComparer.OrdinalIgnoreCase))
    {
        var origin = context.Request.Headers.Origin.FirstOrDefault();
        var originsPermitidas = new[] { "https://app.seudominio.com", "http://localhost:4200" };

        if (!string.IsNullOrEmpty(origin) && !originsPermitidas.Contains(origin))
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsJsonAsync(new
            {
                Erro = "Origin não permitida",
                Origin = origin
            });
            return;
        }
    }

    await next(context);
});

Dicas e Boas Práticas

  • Use sessão distribuída em produção: armazenar sessões apenas em memória não funciona quando você tem múltiplas instâncias do BFF. Utilize Redis ou SQL Server para armazenamento de sessão distribuído, garantindo que qualquer instância possa atender qualquer requisição.

  • Implemente health checks no BFF: o BFF é um ponto crítico da arquitetura. Se ele cair, a SPA inteira fica sem autenticação. Configure health checks, monitoramento e redundância. Considere executar pelo menos 2 réplicas em produção.

  • Separe o BFF da API de negócio: o BFF deve fazer apenas proxy e gerenciamento de sessão. Não coloque lógica de negócio no BFF — mantenha-o leve e com responsabilidade única. As APIs protegidas (Resource Servers) devem validar os access tokens independentemente.

  • Configure timeouts adequados no NGINX e no HttpClient: no NGINX, defina proxy_connect_timeout, proxy_send_timeout e proxy_read_timeout para evitar que APIs lentas bloqueiem o BFF. No ASP.NET Core, o HttpClient nomeado já possui timeout configurado via appsettings.json.

  • Não exponha endpoints internos: certifique-se de que apenas /bff/* e /api/* estejam acessíveis externamente. Endpoints de diagnóstico, métricas e health checks devem estar protegidos ou acessíveis apenas internamente.

  • Monitore o tamanho do cookie de sessão: com SaveTokens = true, os tokens são armazenados dentro do cookie encriptado pelo ASP.NET Core. Tokens do Azure Entra ID podem ser grandes (1-3KB). Se o cookie de sessão ultrapassar 4KB, navegadores podem rejeitá-lo. Nesse caso, migre para sessão server-side com Redis.

  • Teste XSS regularmente: embora o BFF proteja tokens contra XSS, um ataque XSS ainda pode hijack a sessão do usuário (enviando requisições autenticadas em nome dele). Mantenha sanitização de inputs, CSP (Content-Security-Policy) e audite dependências npm com npm audit.

⚠️ Atenção: O BFF não elimina a necessidade de proteger a SPA contra XSS. Ele elimina a possibilidade de roubo de tokens, mas um XSS ativo pode enviar requisições autenticadas enquanto a sessão estiver ativa. Combine BFF com CSP headers, sanitização de inputs e auditorias regulares de dependências.


Conclusão

O padrão BFF (Backend For Frontend) é hoje a abordagem mais segura para autenticação em Single Page Applications, sendo a recomendação oficial da IETF para aplicações baseadas em navegador. Ao mover a gestão de tokens para um servidor intermediário e usar cookies HttpOnly para comunicação com o navegador, eliminamos o principal vetor de ataque — o acesso do JavaScript a credenciais sensíveis.

Neste artigo, implementamos um BFF completo com ASP.NET Core 8 que atua como Confidential Client no OAuth 2.0, gerencia sessões seguras e renova tokens automaticamente. Utilizamos NGINX como reverse proxy na frente da arquitetura — servindo os arquivos estáticos do Angular, encaminhando requisições ao BFF, gerenciando TLS/SSL e aplicando security headers e rate limiting. No lado do front-end, construímos uma SPA Angular 16+ que se beneficia da simplicidade do BFF — sem necessidade de bibliotecas de autenticação, guards baseados em chamadas HTTP e proteção CSRF em múltiplas camadas.

A troca consciente deste padrão é complexidade adicional no backend em favor de segurança significativamente maior. Para aplicações que lidam com dados sensíveis, autenticação de usuários reais e que buscam conformidade com boas práticas de segurança, o BFF é o caminho recomendado. Se você ainda não conhece os fundamentos de OAuth2, JWT e OpenID Connect, confira Autenticação e Autorização: JWT, OAuth2 e OpenID Connect. E para automatizar o build e deploy desta stack, veja Makefile: Automatizando tarefas para Python, Hugo e Docker.


Leia Também


Referências