Mert Özen Her Satırda Daha İleri
Güvenlik Authentication ve Authorization — #14

SPA için BFF Pattern: Angular + ASP.NET Core + YARP ile RFC 10017'nin Önerdiği Mimari

Mert Özen 8 Kas 2026 12 dk 5 görüntülenme
SPA için BFF Pattern: Angular + ASP.NET Core + YARP ile RFC 10017'nin Önerdiği Mimari

Token'ı tarayıcıda hiçbir yerde güvenle saklayamazsın; RFC 10017'nin en güvenli bulduğu çözüm token'ları sunucuda tutan BFF'tir. ASP.NET Core 10'da OIDC ve cookie oturumu, YARP ile API'ye giderken token ekleme, BFF içinde token yenileme, özel header ile CSRF savunması ve XHR uyumlu logout kuruyor; Angular'ın token görmediğini curl çıktılarıyla doğruluyor, maliyetleri ve sınırları tartışıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Yarp.ReverseProxy 2.3.0, Microsoft.AspNetCore.Authentication.OpenIdConnect 10.0.14, Angular 22.2 ve Keycloak 26.8.2 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un Angular uygulaması Keycloak'tan token alıp API'yi (Application Programming Interface) çağırıyor. Token'ı nerede saklamalı: localStorage mı, sessionStorage mı, bellekte mi? Sayfada bir XSS (Cross-Site Scripting) açığı olduğunu varsay; hangisi saldırganı durdurur? Cevap çoğu kişiyi şaşırtır: hiçbiri. Sayfada kod çalıştırabilen saldırgan token'ı çalamasa bile, aynı sayfanın içinden authorization server'dan yeni bir token isteyebilir. Token'ı tarayıcıda tutan her mimaride bu kapı açık.

RFC (Request for Comments) 10017, OAuth 2.0 for Browser-Based Applications, Ağustos 2026'da BCP (Best Current Practice) 212 olarak yayımlandı ve bu soruya mimari düzeyde cevap veriyor. Bu yazıda önerdiği en güvenli mimariyi, BFF (Backend for Frontend) pattern'ini, ASP.NET Core 10 ve YARP (Yet Another Reverse Proxy) ile kuruyor, Angular tarafının token'ı hiç görmediğini curl çıktılarıyla gösteriyoruz. Tarayıcıda token saklamanın tehdit modeli ilerideki "Frontend Güvenliği" serisinin 05. yazısının konusu; burada mimariye odaklanıyoruz.

RFC 10017: Üç Mimari, Güvenlik Sırasıyla

RFC 10017 tarayıcı tabanlı uygulamalar için üç pattern tanımlıyor ve onları güvenlik sırasına diziyor:

PatternOAuth client kimToken'lar neredeSayfada kod çalıştıran saldırgan
BFFSunucudaki BFF (confidential)Yalnız sunucudaToken çalamaz; oturum sürerken BFF üzerinden istek atabilir
Token-mediating backendSunucudaki backend (confidential)Refresh token sunucuda, access token tarayıcıdaAccess token'ı çalabilir, yenisini isteyebilir
Tarayıcıda OAuth clientAngular uygulaması (public)Hepsi tarayıcıdaToken'ları çalabilir, yenilerini alabilir

Belge ayrıca önerilmeyen pattern'leri de sayıyor: implicit grant, password grant, OAuth akışını bir Service Worker'da yürütmek (saldırgan Service Worker'ı devre dışı bırakıp yeni bir bağlamda akışı yeniden başlatabiliyor) ve tek domain'li uygulamalarda OAuth'u gereksiz yere kullanmak. Sonuncusu ilginç: frontend ve API aynı yerdeyse, 02. yazıdaki gibi sıradan bir cookie session'ı çoğu zaman yeterli ve daha basit.

BFF'in tabloda en üstte olmasının sebebi şu: XSS'i tamamen önleyemezsin, ama zararını sınırlayabilirsin. BFF'te saldırgan yalnız kullanıcının oturumu açıkken ve yalnız BFF'in izin verdiği adreslere istek atabiliyor; dışarı taşıyabileceği bir token yok.

BFF'in Parçaları

14-bff-architecture-tr.png

Lab'da üç süreç var. Keycloak http://localhost:19200'de. 13. yazının Bookstore API'si http://localhost:19213'te, değişmeden; JWT (JSON Web Token) Bearer ile korunuyor ve BFF'in varlığından haberi yok. BFF https://localhost:19214'te: Angular'ın derlenmiş dosyalarını sunuyor, Keycloak'la OIDC (OpenID Connect) konuşan confidential client bookstore-bff, kullanıcıyla arasında yalnız bir cookie var ve /api/* isteklerini token ekleyerek API'ye iletiyor. Angular ve BFF aynı origin'de; CORS (Cross-Origin Resource Sharing) yok, preflight yok. BFF'in sertifikası çalışma klasöründe openssl ile üretildi; __Host- cookie'si HTTPS (HTTP Secure) olmadan çalışmıyor.

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        // Challenge cookie handler'a gider ve 401 döner; Keycloak'a yalnız /bff/login yönlendirir
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        // __Host- prefix: Secure, Path=/ ve Domain'siz olmaya zorlar; alt alan adları cookie'yi ezemez
        options.Cookie.Name = "__Host-bookstore";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
        options.SlidingExpiration = true;
        options.Events.OnRedirectToLogin = context =>
        {
            // SPA'nın çağırdığı adreslerde redirect değil 401
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return Task.CompletedTask;
        };
    })
    .AddOpenIdConnect(options =>
    {
        options.Authority = builder.Configuration["Oidc:Authority"];
        options.RequireHttpsMetadata = false; // yalnız yerel lab: Keycloak HTTP üzerinde
        options.ClientId = builder.Configuration["Oidc:ClientId"];
        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
        options.ResponseType = OpenIdConnectResponseType.Code;
        options.Scope.Add("books.read");
        options.Scope.Add("orders.read");
        options.MapInboundClaims = false;
        options.SaveTokens = true; // token'lar şifreli cookie'de, tarayıcıdaki JavaScript'e hiç gitmez
        options.TokenValidationParameters.NameClaimType = "name";
    });

app.MapGet("/bff/login", (string? returnUrl) => Results.Challenge(
    new AuthenticationProperties { RedirectUri = IsLocal(returnUrl) ? returnUrl : "/" },
    [OpenIdConnectDefaults.AuthenticationScheme]));

app.MapGet("/bff/user", (ClaimsPrincipal user) => user.Identity?.IsAuthenticated == true
    ? Results.Ok(new { Sub = user.FindFirstValue("sub"), Name = user.Identity.Name, Email = user.FindFirstValue("email") })
    : Results.Unauthorized());

// Open redirect'e karşı: yalnız uygulama içi adreslere dön
static bool IsLocal([NotNullWhen(true)] string? url) =>
    url is not null && url.StartsWith('/') && !url.StartsWith("//") && !url.StartsWith("/\\");

İki karar önemli. Birincisi DefaultChallengeScheme'in cookie olması: ilk denemede OIDC'yi seçmiştim ve Angular'ın attığı her yetkisiz /api isteği Keycloak'a 302 ile yönlendiriliyordu; bir XHR'ın (XMLHttpRequest) bu redirect'le yapabileceği hiçbir şey yok. Şimdi yetkisiz istek 401 alıyor, girişi Angular /bff/login'e tam sayfa yönlendirmeyle başlatıyor. İkincisi returnUrl kontrolü; giriş sonrası dönüş adresi dışarıdan geliyor ve doğrulanmazsa BFF bir open redirect'e dönüşür.

SameSite için RFC 10017 Strict öneriyor ama zorunlu tutmuyor. Ben Lax bıraktım ve CSRF (Cross-Site Request Forgery) savunmasını aşağıdaki header'a dayandırdım; belge de BFF aynı site'taki başka uygulamalarla yan yanaysa SameSite'ın tek başına yetmediğini söylüyor. Strict seçersen IdP'den (Identity Provider) dönüşteki redirect zincirinde cookie'nin gönderilip gönderilmediğini gerçek bir tarayıcıda test et. Giriş akışını curl'le sürdüğümde son adım:

$ sh login14.sh ada $J
HTTP/2 302
location: /orders
set-cookie: .AspNetCore.Correlation.cbG00xlo2JogDICKU4AV-Ppr1U46F23fAKi8E3qpzxE=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/signin-oidc; secure; sa...
set-cookie: .AspNetCore.OpenIdConnect.Nonce.CfDJ8EINq_wm4TFCv7guzy6SHCcNJV0FuWfwXAtBAX15ghV-FlBZqngUt63uPD_QVwAzlb92e6...
set-cookie: __Host-bookstore=chunks-2; path=/; secure; samesite=lax; httponly
set-cookie: __Host-bookstoreC1=CfDJ8EINq_wm4TFCv7guzy6SHCe2uLOq0iX51I3zPsu9Al8ZkrI2a9a_7joHS7gnYOyS3mppC_xwyt_1Q-UZVHd...
set-cookie: __Host-bookstoreC2=mXFQCXydyMwFIVnoCaIky8RU1oYCCSYGODtKgWOAhdDkACcVJUghxuaffBjXqTxH3VwUOpXKBD2bh0tLYsh7YBE...
$ curl -sk -b $J -H "X-CSRF: 1" https://localhost:19214/bff/user
{"sub":"813c410a-8e19-4979-85aa-5cc46f3cb5ab","name":"Ada Lovelace","email":"ada@bookstore.test"}

Tarayıcının elinde iki parçalı, Data Protection ile şifrelenmiş bir cookie var; access, refresh ve ID token'ı onun içinde. Angular'ın gördüğü tek şey /bff/user'ın döndüğü üç alan. Cookie'nin büyüklüğü rahatsız ediyorsa token'ları 02. yazıdaki ITicketStore ile sunucuda tutup cookie'de yalnız bir anahtar bırakabilirsin.

YARP: Token'ı Sunucuda Eklemek

BFF'in ikinci işi, Angular'dan gelen /api/* isteklerini API'ye iletirken cookie'yi çıkarıp access token'ı koymak. YARP bunun için yazılmış bir reverse proxy library'si:

builder.Services.AddReverseProxy()
    .LoadFromMemory(
        [
            new RouteConfig
            {
                RouteId = "api",
                ClusterId = "bookstore-api",
                AuthorizationPolicy = "default",
                Match = new RouteMatch { Path = "/api/{**rest}" },
                Transforms = [new Dictionary<string, string> { ["PathRemovePrefix"] = "/api" }]
            }
        ],
        [
            new ClusterConfig
            {
                ClusterId = "bookstore-api",
                Destinations = new Dictionary<string, DestinationConfig>
                {
                    ["api"] = new() { Address = builder.Configuration["Api:Address"]! }
                }
            }
        ])
    .AddTransforms(context =>
    {
        // Cookie API'ye gitmez; yerine kullanıcının access token'ı eklenir
        context.AddRequestHeaderRemove("Cookie");
        context.AddRequestTransform(async transform =>
        {
            UserTokenRefresher refresher = transform.HttpContext.RequestServices.GetRequiredService<UserTokenRefresher>();
            string? accessToken = await refresher.GetAccessTokenAsync(transform.HttpContext);
            if (accessToken is not null)
            {
                transform.ProxyRequest.Headers.Authorization = new("Bearer", accessToken);
            }
        });
    });

AuthorizationPolicy = "default" route'u giriş yapmış kullanıcıya kısıtlıyor; BFF yetkisiz bir isteği API'ye hiç iletmiyor. Route listesi aynı zamanda RFC 10017'nin istediği izin listesi: BFF yalnız burada tanımlı hedeflere proxy'liyor, istemcinin verdiği bir adrese değil. Sonuç:

$ curl -sk -b $J -H "X-CSRF: 1" https://localhost:19214/api/orders/mine
{"customer":"ada@bookstore.test","client":"bookstore-bff"}
$ curl -sk -b $J -H "X-CSRF: 1" https://localhost:19214/api/books
["Dune","Solaris","Neuromancer"]

API cevabındaki client alanı azp claim'inden geliyor: API, isteğin bookstore-bff client'ının Ada adına aldığı token'la geldiğini görüyor. 13. yazıdaki audience, scope ve end-user policy'leri hiç değişmeden çalıştı.

Token Yenileme BFF'in İşi

Lab'da bookstore-bff client'ının access token ömrünü 60 saniyeye indirdim ki yenileme görünsün. Access token süresi dolmak üzereyse BFF refresh token'la yenisini alıyor ve cookie'yi güncelliyor:

// Access token'ın süresi dolmak üzereyse refresh token ile yeniler ve cookie'deki token'ları günceller
public sealed class UserTokenRefresher(
    IHttpClientFactory httpFactory, IOptionsMonitor<OpenIdConnectOptions> oidcOptions,
    TimeProvider clock, ILogger<UserTokenRefresher> logger)
{
    private static readonly TimeSpan RefreshBefore = TimeSpan.FromSeconds(30);

    public async Task<string?> GetAccessTokenAsync(HttpContext http)
    {
        AuthenticateResult auth = await http.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        if (!auth.Succeeded)
        {
            return null;
        }

        AuthenticationProperties properties = auth.Properties!;
        string? accessToken = properties.GetTokenValue("access_token");
        DateTimeOffset expiresAt = DateTimeOffset.Parse(properties.GetTokenValue("expires_at")!, CultureInfo.InvariantCulture);
        if (expiresAt - RefreshBefore > clock.GetUtcNow())
        {
            return accessToken;
        }

        OpenIdConnectOptions options = oidcOptions.Get(OpenIdConnectDefaults.AuthenticationScheme);
        string tokenEndpoint = (await options.ConfigurationManager!.GetConfigurationAsync(http.RequestAborted)).TokenEndpoint;
        using HttpResponseMessage response = await httpFactory.CreateClient().PostAsync(tokenEndpoint, new FormUrlEncodedContent(
        [
            new("grant_type", "refresh_token"),
            new("refresh_token", properties.GetTokenValue("refresh_token")!),
            new("client_id", options.ClientId!),
            new("client_secret", options.ClientSecret!)
        ]), http.RequestAborted);
        if (!response.IsSuccessStatusCode)
        {
            // Refresh token iptal edilmiş ya da süresi dolmuş: oturumu kapat, SPA 401 alıp yeniden giriş başlatır
            logger.LogWarning("Refresh failed with {Status}; signing out", (int)response.StatusCode);
            await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return null;
        }

        TokenResponse tokens = (await response.Content.ReadFromJsonAsync<TokenResponse>(http.RequestAborted))!;
        properties.UpdateTokenValue("access_token", tokens.AccessToken);
        properties.UpdateTokenValue("refresh_token", tokens.RefreshToken);
        properties.UpdateTokenValue("expires_at",
            clock.GetUtcNow().AddSeconds(tokens.ExpiresIn).ToString("o", CultureInfo.InvariantCulture));
        await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, auth.Principal!, properties);
        logger.LogInformation("Access token refreshed, new expiry in {Seconds}s", tokens.ExpiresIn);
        return tokens.AccessToken;
    }

    private sealed record TokenResponse(
        [property: JsonPropertyName("access_token")] string AccessToken,
        [property: JsonPropertyName("refresh_token")] string RefreshToken,
        [property: JsonPropertyName("expires_in")] int ExpiresIn);
}
t~0s: 200
t~20s: 200
t~35s: 200
info: Bookstore.Bff.UserTokenRefresher[0]
      Access token refreshed, new expiry in 60s

Angular bu yenilemeden habersiz; her istekte aynı cookie'yi gönderiyor, yeni cookie cevapla birlikte geliyor. Tarayıcıda public client kullanan bir uygulamada aynı işi Angular yapmak zorunda; eşzamanlı 401'leri tek bir refresh'e bağlamak 17. yazının konusu. Bu sınıfın eksiği de orada çözülen problem: aynı kullanıcının iki eşzamanlı isteği aynı anda refresh yapabilir. Keycloak'ta refresh token rotation (revokeRefreshToken) kapalı olduğu için lab'da sorun çıkmıyor; açarsan ikinci refresh reddedilir ve kullanıcı oturumdan düşer. O durumda refresh'i kullanıcı başına kilitle ya da Duende.AccessTokenManagement.OpenIdConnect gibi bunu yapan bir library kullan.

CSRF ve Logout

Oturum bir cookie'ye dayandığı anda CSRF geri geliyor: başka bir sitedeki form, tarayıcının BFF'e cookie'li bir istek atmasına yol açabilir. RFC 10017'nin önerdiği savunma her isteğin özel bir header taşıması; başka origin'den gelen bir istek bu header'ı ancak CORS preflight'ından geçerek ekleyebilir ve BFF hiçbir origin'e CORS izni vermiyor:

// Anti-CSRF: /api ve /bff çağrıları özel bir header taşımalı; çapraz origin'den bu header preflight'sız gönderilemez
app.Use(async (context, next) =>
{
    bool protectedPath = context.Request.Path.StartsWithSegments("/api")
        || (context.Request.Path.StartsWithSegments("/bff") && !context.Request.Path.StartsWithSegments("/bff/login"));
    if (protectedPath && context.Request.Headers["X-CSRF"] != "1")
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        await context.Response.WriteAsync("Missing X-CSRF header");
        return;
    }
    await next();
});

Angular tarafında bunu bir functional interceptor ekliyor; header yalnız kendi origin'imize giden isteklere gidiyor:

import { HttpInterceptorFn } from '@angular/common/http';

// BFF anti-CSRF header'ı: yalnız kendi origin'imize giden isteklere eklenir
export const csrfHeaderInterceptor: HttpInterceptorFn = (req, next) => {
  const sameOrigin = req.url.startsWith('/') && !req.url.startsWith('//');
  return next(sameOrigin ? req.clone({ setHeaders: { 'X-CSRF': '1' } }) : req);
};
// app.config.ts
provideHttpClient(withInterceptors([csrfHeaderInterceptor]))

//cdn.example.com gibi protocol-relative adresler de / ile başlıyor; ikinci koşul onlar için. Interceptor'ın testi bunu sabitliyor (Vitest, Angular 22'nin varsayılan test runner'ı):

it('does not leak the header to other origins', () => {
  http.get('https://cdn.example.com/covers.json').subscribe();
  http.get('//cdn.example.com/covers.json').subscribe();
  expect(backend.expectOne('https://cdn.example.com/covers.json').request.headers.has('X-CSRF')).toBe(false);
  expect(backend.expectOne('//cdn.example.com/covers.json').request.headers.has('X-CSRF')).toBe(false);
});

Header'sız bir istek, örneğin başka bir sitenin formu gibi davranan bir curl:

$ curl -sk -b $J -o /dev/null -w "no csrf: %{http_code}\n" https://localhost:19214/api/books
no csrf: 400

Logout'ta küçük bir sorun çıktı. Klasik çözüm bir formla /bff/logout'a POST etmek ve BFF'in tarayıcıyı Keycloak'a yönlendirmesi; ama form özel header ekleyemiyor, XHR ise Keycloak'a yapılan cross-origin redirect'i takip edemiyor. Ben ikiye böldüm: Angular XHR ile POST ediyor, BFF cookie'yi silip Keycloak'un logout adresini JSON (JavaScript Object Notation) olarak dönüyor, Angular tarayıcıyı oraya götürüyor:

// SPA XHR ile çağırır: cookie silinir, Keycloak'un logout adresi döner ve SPA tarayıcıyı oraya götürür
app.MapPost("/bff/logout", async (HttpContext http, IOptionsMonitor<OpenIdConnectOptions> oidcOptions) =>
{
    string? idToken = await http.GetTokenAsync("id_token");
    await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    OpenIdConnectOptions options = oidcOptions.Get(OpenIdConnectDefaults.AuthenticationScheme);
    OpenIdConnectConfiguration config = await options.ConfigurationManager!.GetConfigurationAsync(http.RequestAborted);
    string logoutUrl = QueryHelpers.AddQueryString(config.EndSessionEndpoint, new Dictionary<string, string?>
    {
        ["id_token_hint"] = idToken,
        ["post_logout_redirect_uri"] = $"{http.Request.Scheme}://{http.Request.Host}/"
    });
    return Results.Ok(new { LogoutUrl = logoutUrl });
}).RequireAuthorization();
no csrf: 400
{"logoutUrl":"http://localhost:19200/realms/bookstore/protocol/openid-connect/logout?id_token_hint=eyJ...&post_logout_redirect_uri=https%3A%2F%2Flocalhost%3A19214%2F"}
302 https://localhost:19214/
user after logout: 401

İlk denemede post_logout_redirect_uri'yi OIDC handler'ın /signout-callback-oidc adresine vermiştim; handler kendi başlatmadığı (state'siz) bir dönüşte boş bir 200 döndü. Logout'u handler'ın dışında başlattığın için dönüş adresi doğrudan uygulamanın kökü olmalı ve Keycloak'ta öyle kayıtlı olmalı.

Maliyet ve Sınırlar

BFF bedava değil. Her API çağrısı fazladan bir hop: tarayıcı → BFF → API. Lab'da curl ile 30 isteğin ortalamasını aldım; bağlantı kurulumu hariç API'ye doğrudan istek 0,25 ms, BFF üzerinden 0,66 ms sürdü. Hepsi aynı makinede, ağ gecikmesi yok; gerçek dağıtımda BFF ile API arasındaki ağ gecikmesi buna eklenir. Asıl bedel süre değil: BFF artık kritik yolda, çökerse uygulama çöker. BFF durumlu: oturum cookie'de olsa da Data Protection key ring'i instance'lar arasında paylaşılmalı, ITicketStore kullanıyorsan session deposu da. Büyük dosya yükleme ve indirme, WebSocket ve Server-Sent Events de proxy'den geçiyor; YARP bunları destekliyor ama timeout ve body limit'leri BFF'te de ayarlanmalı.

BFF'in en sert sınırı aynı origin gereksinimi: Angular BFF'ten sunulmalı ya da BFF'le aynı site'ta olmalı. Angular'ı bir CDN'den (Content Delivery Network) başka bir domain'de sunuyorsan cookie üçüncü taraf cookie olur ve tarayıcılar onu giderek daha çok engelliyor. RFC 10017 de BFF'in CDN ile ayrı servisler olarak dağıtılabileceğini söylüyor; şart, ikisinin aynı site'ın altında olması.

Tarayıcıda public client ne zaman kabul edilebilir? Sunucu tarafı bileşen çalıştırmanın gerçekten mümkün olmadığı yerlerde (statik hosting'deki küçük bir araç, düşük riskli veri) ve şu şartlarla: PKCE (Proof Key for Code Exchange), refresh token rotation ya da DPoP (Demonstrating Proof of Possession), kısa token ömürleri, sıkı bir CSP (Content Security Policy). Ödeme, kişisel veri ya da yönetim işlemi varsa BFF.

Üretimde Dikkat

BFF'in arkasındaki API'ye doğrudan erişimi kapat ya da en azından API'nin yalnız token'la çalıştığından emin ol; BFF'i atlayan bir istek de API'nin kendi doğrulamasından geçmek zorunda. Route listesini dar tut: /api/{**rest} tek bir API'ye gidiyor; birden fazla API varsa her biri için ayrı route, ayrı audience'lı token. Proxy'lenen cevaplardaki Set-Cookie header'larına da dikkat; API bir cookie basıyorsa BFF onu tarayıcıya geçirir.

Anti-CSRF header'ını kontrol eden middleware'in UseAuthorization'dan önce çalıştığından ve yeni eklenen her /bff endpoint'ini kapsadığından emin ol; istisna listesi (burada yalnız /bff/login) kısa kalmalı. Cookie ömrünü refresh token ömrüyle uyumlu ayarla: cookie 8 saat yaşarken refresh token 30 dakikada ölürse kullanıcı oturumu açık sanıp 401 alır.

Hazır bir çözüm istiyorsan Duende BFF Security Framework bu yazıdaki parçaların hepsini (oturum yönetimi, token yenileme, anti-CSRF, YARP entegrasyonu, back-channel logout) paket olarak sunuyor; ticari lisanslı, koşullarını yazım anında kontrol et. Kendi BFF'ini yazmak bu yazıdaki kadar kod; zor olan, buradaki her kenar durumu test etmek.

Sık Düşülen Tuzaklar

Birincisi, BFF'i kurup /bff/user'dan access token döndürmek; mimarinin bütün faydası gider, bu artık token-mediating backend. İkincisi, YARP route'una authorization policy koymamak; BFF anonim istekleri token'sız iletir ve hata ayıklaması zor 401'ler başlar. Üçüncüsü, proxy'de Cookie header'ını API'ye geçirmek; API'nin log'larında oturum cookie'leri birikir.

Dördüncüsü, SameSite'ı tek CSRF savunması saymak. Beşincisi, returnUrl'i doğrulamamak. Altıncısı, BFF'i ayrı bir domain'e koyup cookie'yi SameSite=None ile "çalıştırmak"; bu hem üçüncü taraf cookie engellerine takılır hem de CSRF yüzeyini büyütür.

Küçük Bir Deneme

★☆☆ DefaultChallengeScheme'i OpenIdConnectDefaults.AuthenticationScheme yap ve giriş yapmadan /api/books'u X-CSRF header'ıyla çağır. Cevabı yaz ve bir XHR'ın onunla ne yapabileceğini açıkla.

★★☆ Keycloak'ta realm'in "Revoke Refresh Token" ayarını aç, access token 60 saniyedeyken aynı cookie'yle iki isteği tam yenileme anında eşzamanlı gönder. Biri başarısız oluyor mu? UserTokenRefresher'a kullanıcı başına bir kilit ekleyip tekrar dene.

★★★ Token'ları cookie'den çıkar: ITicketStore'u Redis ya da EF (Entity Framework) Core ile yaz, cookie'nin tek parçaya indiğini ve 12. yazıdaki back-channel logout'un artık doğrudan session kaydını silebildiğini göster.

Angular artık token görmüyor; token'ları basan ve oturumu yöneten taraf ise bütün bu yazılarda Keycloak'tu. Bir sonraki yazıda o rolü üstlenebilecek seçenekleri karşılaştırıyoruz: OpenIddict, Duende IdentityServer, Keycloak ve Microsoft Entra ID; kendin barındırmak ile managed servis arasındaki fark, lisanslar, standart desteği ve "kendi authorization server'ını yazma" uyarısı.