Authentication ve Service-to-Service Güven: Keycloak, BFF Session'ı, Token Propagation ve Client Credentials
Keycloak realm'i secret'sız bir JSON'dan import ediliyor; BFF kullanıcıyı OIDC ile giriş yaptırıp token'ları HttpOnly cookie oturumunda tutuyor, sipariş route'larında kullanıcının access token'ını Ordering'e taşıyor ve stok sorgusu için kendi client credentials token'ını kullanıyor. Her service token'ı audience ve rol ile kendisi doğruluyor; Inventory için alınmış bir token Ordering'de 401 alıyor ve kimlik event'lere token değil CustomerId olarak giriyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6 (Aspire.Hosting.Keycloak ve Aspire.Keycloak.Authentication 13.6.5-preview), Keycloak 26.6, YARP (Microsoft'un .NET için reverse proxy library'si) 2.3 ve Grpc.Net.ClientFactory 2.85 ile derlenip denenmiştir.
Önce Tahmin Et
Ada BFF (Backend for Frontend; Angular'a özel ara backend) üzerinden giriş yapıyor ve BFF, Keycloak'tan aldığı token'ları oturum cookie'sinin içinde saklıyor. Tarayıcıya kaç cookie gider? İkinci soru: iç ağdaki bir script BFF'yi atlayıp Ordering'i doğrudan çağırıyor ve elinde Keycloak'tan alınmış, imzası geçerli bir token var; ama bu token Inventory için alınmış. Ordering ne der? Cevaplar gerçek çalıştırmalarda.
Keycloak Realm'i: Lokalde Import, Secret'lar Repo Dışında
02'den beri AppHost'ta bir Keycloak container'ı vardı, ama içi boştu; 06'da onu WithExplicitStart() ile kapalı başlatmıştık. Şimdi sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) bookstore realm'ini tanımlıyoruz: iki realm rolü (customer ve store-staff), iki deneme kullanıcısı (Ada müşteri, Grace aynı zamanda mağaza çalışanı) ve iki client. bookstore-bff, kullanıcı girişi için authorization code + PKCE (Proof Key for Code Exchange; authorization code'un çalınmasına karşı koruma) akışını kullanan gizli bir client. bookstore-bff-service ise BFF'nin kullanıcı adına olmayan çağrıları için bir service hesabı. Realm, AppHost'un Realms klasöründeki JSON dosyasından ilk açılışta import ediliyor:
{
"clientId": "bookstore-bff",
"publicClient": false,
"secret": "${BOOKSTORE_BFF_CLIENT_SECRET}",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [ "http://localhost:5100/signin-oidc" ],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:5100/signout-callback-oidc"
},
"protocolMappers": [
{
"name": "bookstore-api audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": { "included.custom.audience": "bookstore-api", "access.token.claim": "true", "id.token.claim": "false" }
},
{
"name": "realm roles as roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": { "claim.name": "roles", "multivalued": "true", "jsonType.label": "String", "access.token.claim": "true", "id.token.claim": "true" }
}
]
}
İki mapper iki işi yapıyor. Audience mapper, BFF için verilen access token'lara bookstore-api audience'ını ekliyor; service'ler yalnız bu audience'a sahip token'ları kabul edecek. Rol mapper'ı ise Keycloak'un rolleri koyduğu iç içe realm_access.roles yapısı yerine düz bir roles claim'i üretiyor; ASP.NET Core'un RequireRole'ü bunu doğrudan okuyabiliyor.
Dosyada hiçbir secret yok. ${BOOKSTORE_BFF_CLIENT_SECRET} gibi değerleri Keycloak import sırasında kendi environment'ından dolduruyor; o environment'ı da AppHost veriyor:
// Bir kez üretiliyor, AppHost'un user secrets'ında tutuluyor, repository'ye hiç girmiyor. Realm dosyası
// bunlara ${...} placeholder'ı ile başvuruyor; Keycloak import sırasında onları environment'ından dolduruyor.
IResourceBuilder<ParameterResource> bffClientSecret = AddGeneratedSecret(builder, "bff-client-secret");
IResourceBuilder<ParameterResource> bffServiceSecret = AddGeneratedSecret(builder, "bff-service-secret");
IResourceBuilder<ParameterResource> demoPassword = AddGeneratedSecret(builder, "keycloak-demo-password");
// "bookstore" realm'i Realms/ klasöründen import edilen lokal kimlik sağlayıcısı. Production'da yönetilen bir
// Keycloak (ya da başka bir OIDC sağlayıcısı) ve kendi realm yönetimi var; import yalnız geliştirme için.
IResourceBuilder<KeycloakResource> keycloak = builder.AddKeycloak("keycloak", 8180)
.WithContainerName("msapp-keycloak")
.WithDataVolume("msapp-keycloak-data")
.WithLifetime(ContainerLifetime.Persistent)
.WithRealmImport("./Realms")
.WithEnvironment("BOOKSTORE_BFF_CLIENT_SECRET", bffClientSecret)
.WithEnvironment("BOOKSTORE_BFF_SERVICE_SECRET", bffServiceSecret)
.WithEnvironment("BOOKSTORE_DEMO_PASSWORD", demoPassword);
AddGeneratedSecret, 08'de webhook secret'ını üreten kodu AppHost'un sonundaki küçük bir yardımcı method'a taşıyor. Deneme kullanıcılarının şifresi dashboard'da keycloak-demo-password parametresinde görünüyor. Keycloak log'u import'u doğruladı: Realm 'bookstore' imported. Import, realm yoksa çalışıyor (Strategy: IGNORE_EXISTING); realm dosyasını değiştirdiğinde msapp-keycloak-data volume'unu silmen gerekiyor. Aspire'ın Keycloak paketleri, hem AppHost tarafı hem service'lerin kullandığı Aspire.Keycloak.Authentication, bu yazının yazıldığı gün hâlâ preview. Production'da realm'i container açılışında import etmek yerine ayrı bir araçla, sürüm kontrolünde yönetmek gerekiyor.
BFF: OIDC ile Giriş, Cookie ile Oturum
Authentication ve Authorization serisinin SPA (Single Page Application; tarayıcıda tek sayfa olarak çalışan uygulama) için BFF Pattern yazısındaki yapı burada kuruluyor: Angular hiçbir token görmüyor. Giriş BFF'de OIDC (OpenID Connect; OAuth 2.0 üstündeki kimlik katmanı) ile yapılıyor, token'lar BFF'nin oturumunda kalıyor ve tarayıcıya yalnız HttpOnly bir cookie gidiyor:
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// Oturumu olmayan bir API çağrısı 401 alıyor; tarayıcıyı Keycloak'a yalnız /bff/login gönderiyor
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Cookie.Name = "bookstore.session";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
})
.AddKeycloakOpenIdConnect("keycloak", realm: "bookstore", OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.ClientId = "bookstore-bff";
options.ClientSecret = configuration["Keycloak:ClientSecret"];
options.ResponseType = "code";
options.SaveTokens = true;
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "roles";
options.RequireHttpsMetadata = isProduction;
});
AddKeycloakOpenIdConnect, Keycloak'un adresini service discovery ile buluyor (keycloak resource'u ve realm adı). BFF'de üç endpoint var: /bff/login OIDC challenge'ı başlatıyor, /bff/logout hem cookie'yi hem Keycloak oturumunu kapatıyor (denemede logout'tan sonra /bff/user yine 401 döndü), /bff/user Angular'a kullanıcının adını, e-postasını ve rollerini veriyor; token'ı değil. Girişi tarayıcının yaptığı gibi, Keycloak'un login formunu doldurarak yapan küçük bir script ile denedik:
login ada: HTTP 200, ended at /
cookies on localhost:5100 side: ['bookstore.session', 'bookstore.sessionC1', 'bookstore.sessionC2']
GET /bff/user -> HTTP 200
{"name":"Ada Lovelace","email":"ada@example.com","roles":["customer"]}
POST /api/orders -> HTTP 400
{"title":"Missing X-CSRF header"}
POST /api/orders -> HTTP 202 Location: /api/orders/01a1da9e-9876-7c65-b35a-f00965282c23
Önce Tahmin Et'in ilk cevabı: üç. SaveTokens = true ile access, refresh ve ID token'ı cookie'nin içinde şifreli olarak duruyor ve bu, tek bir cookie'nin sınırını aşıyor; ASP.NET Core cookie'yi parçalara bölüyor. Her istekte birkaç KB fazladan header demek. Üretimde oturumu sunucu tarafında (ör. Redis'te) tutup cookie'de yalnız bir anahtar taşımak daha iyi; aşağıda ona döneceğiz.
İlk POST'un 400 alması bilinçli. Cookie, tarayıcının attığı her isteğe eklenir, başka bir sitenin formundan gelen isteğe de. BFF, /api altındaki GET dışı her istekte bir X-CSRF: 1 header'ı istiyor. Başka bir sitedeki form ya da resim etiketi bu header'ı ekleyemiyor; Angular'ın HttpClient'ı ise kolayca ekliyor (11). CSRF (Cross-Site Request Forgery; kullanıcının tarayıcısına başka bir sitenin onun adına istek attırması) korumasının cookie SameSite=Strict olsa bile bir katman daha olarak durması iyi bir alışkanlık. Oturumsuz istekler de doğru davranıyor: /bff/user ve POST /api/orders 401, kitap sayfası ise herkese açık ve 200.
Token Propagation: Kullanıcının Token'ı Ordering'e
BFF'nin proxy route'ları kullanıcının access token'ını arkadaki service'e taşıyor. Sipariş route'ları YARP'ta "AuthorizationPolicy": "default" ile oturum istiyor; token'ı ekleyen bir request transform'u var:
// Token propagation: oturumdaki kullanıcının access token'ı service'e Bearer token olarak gidiyor
context.AddRequestTransform(async transform =>
{
if (transform.HttpContext.User.Identity?.IsAuthenticated == true &&
await transform.HttpContext.GetTokenAsync("access_token") is { } accessToken)
{
transform.ProxyRequest.Headers.Authorization = new("Bearer", accessToken);
}
});
Ordering token'ı kendisi doğruluyor ve siparişi verenin kim olduğunu artık istek body'sinden değil, token'dan alıyor. 04'ten beri yorumlarda söz verdiğimiz değişiklik bu:
// Keycloak'un verdiği adlarıyla claim'ler (MapInboundClaims = false orijinal adları koruyor)
private static Buyer BuyerFrom(ClaimsPrincipal user) => new(
user.FindFirstValue("sub") ?? throw new InvalidOperationException("The access token has no subject."),
user.FindFirstValue("name") ?? "",
(user.FindFirstValue("email") ?? "").ToLowerInvariant());
Buyer'a kimlik sağlayıcısının değişmeyen kullanıcı kimliği (sub) eklendi ve bir migration ile Orders tablosuna Buyer_Id kolonu geldi. "Siparişlerim" listesi artık e-posta parametresiyle değil, token'daki sub ile çalışıyor; başka birinin sipariş id'si de var olmayan bir id ile aynı 404'ü alıyor. Bu bir contract değişikliği ve oasdiff onu üç uyarıyla bildirdi: customerName ve customerEmail alanları POST'tan, customerEmail parametresi GET'ten kalktı. Henüz bu API'yi kullanan bir istemci olmadığı için değişikliği kabul ettik. Ordering tarafında doğrulama ve yetki:
// Her sipariş endpoint'i bookstore-api audience'lı bir müşteri access token'ı istiyor. BFF onu taşıyor.
builder.Services.AddAuthentication()
.AddKeycloakJwtBearer("keycloak", realm: "bookstore", options =>
{
options.Audience = "bookstore-api";
options.MapInboundClaims = false;
options.TokenValidationParameters.RoleClaimType = "roles";
options.RequireHttpsMetadata = builder.Environment.IsProduction();
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("customer", policy => policy.RequireRole("customer"));
Yetkiyi rol ile verdik. Keycloak'ta her API için ayrı client scope'ları tanımlayıp scope bazlı policy'ler de kurulabilirdi; realm'in varsayılan scope'larını bozmadan bunu yapmak ayrı bir iş ve Authentication ve Authorization serisinin API'yi Korumak yazısında ayrıntılı. Bookstore'da "kim" sorusunun cevabı rol, "hangi API için" sorusunun cevabı audience.
Her Service Kendi Token'ını Doğruluyor
Microservices serisinin güvenlik yazısındaki kural: iç ağa güvenme, her service gelen token'ı kendisi doğrulasın. Önce Tahmin Et'in ikinci sorusu tam bunu sınıyor. bookstore-bff-service client'ı ile client credentials token'ı aldık (audience'ı inventory-api) ve BFF'yi atlayıp Ordering'e gönderdik:
{'iss': 'http://localhost:8180/realms/bookstore', 'aud': ['inventory-api', 'account'], 'azp': 'bookstore-bff-service', 'scope': 'profile email'}
GET /api/v1/orders (no token)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
GET /api/v1/orders (token issued for Inventory)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The audience '(null)' is invalid"
İmza geçerli, issuer doğru, ama audience yanlış: token reddedildi. Hata mesajındaki (null), token doğrulamasını yapan IdentityModel library'sinin kişisel bilgi sayılabilecek değerleri varsayılan olarak gizlemesinden geliyor. Catalog'un yönetim endpoint'leri store-staff rolü istiyor. Aynı fiyat değişikliğini BFF üzerinden Ada ve Grace ile gönderdik:
login ada: HTTP 200, ended at /
PUT /api/catalog/admin/books/9780141439549/price -> HTTP 403
login grace: HTTP 200, ended at /
PUT /api/catalog/admin/books/9780141439549/price -> HTTP 204
401 ile 403'ün farkı burada görünüyor: Ada kim olduğunu kanıtladı ama bu işi yapmaya yetkisi yok. Grace'in değişikliği 06'daki outbox ile Ordering'in kopyasına ulaştı (PriceVersion 2).
Inventory İçin Client Credentials
Kitap sayfasının stok sorgusu bir kullanıcı işlemi değil; giriş yapmamış biri de kitap sayfasını görüyor. BFF bu çağrıyı kendi kimliğiyle yapıyor: OAuth 2.0 client credentials ile bookstore-bff-service için bir token alıyor, süresi dolmadan 30 saniye öncesine kadar bellekte tutuyor ve gRPC çağrısına ekliyor. Inventory yalnız inventory-api audience'lı token'ları kabul ediyor. Kullanıcının token'ını Inventory'ye taşımak iki şeyi bozardı: anonim kullanıcının token'ı yok ve kullanıcının token'ının Inventory'ye erişimi olmamalı.
builder.Services.AddGrpcClient<InventoryService.InventoryServiceClient>(options => options.Address = new Uri("http://_grpc.inventory"))
// Stok sorgusu kullanıcının değil, BFF'nin kendi çağrısı: BFF'nin client credentials token'ını taşıyor
.AddCallCredentials(async (_, metadata, services) =>
metadata.Add("Authorization", $"Bearer {await services.GetRequiredService<ServiceTokenProvider>().GetAsync(default)}"))
// gRPC, TLS'siz bir channel'da call credential göndermeyi reddediyor. Lokalde channel düz HTTP/2;
// production'da onu TLS (ya da service mesh) koruyor ve bu ayar kalkıyor.
.ConfigureChannel(channel => channel.UnsafeUseInsecureChannelCallCredentials = !builder.Environment.IsProduction());
Son satır olmadan kitap sayfası 22 ms'de 500 döndü ve log sebebi açıkça söyledi: Call credential configured for gRPC client 'InventoryServiceClient' requires TLS, and the client isn't configured to use TLS. Hata RpcException değil InvalidOperationException olduğu için 09'da kurduğumuz "stok yoksa sayfa yine gelsin" mantığı onu yakalamadı. 05'teki cleartext HTTP/2 kararının bir bedeli daha. Token olmadan Inventory'yi 05'teki deneme aracıyla çağırınca sonuç: Unauthenticated: Bad gRPC response. HTTP status code: 401.
Event'lerle Akan Kimlik
Saga'nın ve consumer'ların arkasında bir HTTP isteği yok; Inventory'ye ReserveStock gönderen bir kullanıcı değil, Ordering. Bu yüzden mesajlar token taşımıyor. "Bu işi kim başlattı" bilgisi gerekiyorsa mesajın bir alanı oluyor:
// CustomerId kimlik sağlayıcısının subject'i: consumer'lar işi kimin yaptığını token'dan değil, mesajdan öğreniyor
public sealed record OrderPlaced(
Guid OrderId, string CustomerId, string CustomerEmail, IReadOnlyList<OrderPlacedLine> Lines, decimal Total, string Currency,
DateTimeOffset PlacedAtUtc);
OrderConfirmed ve OrderCancelled'a da aynı alan eklendi (06'daki kurala göre alan eklemek kırıcı değil) ve saga state'i onu taşıyor. 11'de Notifications bu alanla doğru kullanıcıya SignalR mesajı gönderecek. Ada'nın siparişinden sonra Notifications'ın log'u:
[notifications] Order 01a1da9e-9876-7c65-b35a-f00965282c23 placed by 80b5e1c1-1bd5-4d68-bf17-e697950727c5 (ada@example.com): 199.90 TRY, 1 line(s)
Consumer'ların kendi kimliği de RabbitMQ bağlantısının kimliği: her service broker'a kendi kullanıcısıyla bağlanmalı ve yalnız kendi queue'larını okuyabilmeli. Lokalde hepsi aynı kullanıcıyı kullanıyor; production'da service başına bir RabbitMQ kullanıcısı ve yalnız kendi queue'larına yetki tanımlanmalı.
Üretimde Dikkat
Token yenileme eksik. Keycloak'ta access token 5 dakika geçerli (accessTokenLifespan: 300), BFF'nin cookie oturumu daha uzun. Beş dakika sonra BFF süresi dolmuş token'ı taşır ve Ordering 401 döner. BFF'nin refresh token ile access token'ı yenilemesi gerekiyor; bunu elle yazmak yerine Duende.AccessTokenManagement.OpenIdConnect gibi bir library kullanılabilir. Ayrıntılar Authentication ve Authorization serisinin SPA için BFF yazısında.
Oturumu sunucuda tut. Üç parçalı cookie yerine cookie authentication'ın SessionStore'una Redis'te çalışan bir ITicketStore vermek, cookie'yi küçük bir anahtara indirir ve logout'ta oturumu sunucuda gerçekten siler.
HTTPS ve cookie ayarları. Production'da cookie Secure olmalı ve adı __Host- önekini almalı; bu önek cookie'nin yalnız HTTPS'te, tam o host için ve Path=/ ile kurulmasını zorunlu kılıyor. Lokalde düz HTTP kullandığımız için bu ayarları yalnız production'da açıyoruz.
Secret'ları döndür. Client secret'lar Aspire parametrelerinde; production'da bir secret store'dan gelmeli ve Keycloak ile BFF'de birlikte, kesintisiz değiştirilebilmeli. Client secret yerine imzalı bir JWT (JSON Web Token; imzalı ve kendi bilgisini taşıyan token formatı) ile client authentication da bir seçenek.
Sık Düşülen Tuzaklar
İç ağa güvenip service'lerde authentication'ı kapatmak. "Zaten BFF'nin arkasında" diyen bir service, BFF'yi atlayabilen herhangi bir iç süreci ya da ele geçirilmiş bir pod'u kabul eder. Yukarıdaki audience kontrolü bu yüzden her service'te.
Access token'ı event payload'ına koymak. Token kısa ömürlü; mesaj saatlerce queue'da bekleyebilir, DLQ'da (Dead Letter Queue; işlenemeyen mesajların ayrıldığı queue) günlerce kalabilir ve okuyan herkes kullanıcı adına istek atabilir. Mesaja token değil, kimin yaptığı bilgisi girer.
Token'ı Angular'a vermek. Tarayıcıdaki bir token XSS (Cross-Site Scripting; sayfaya zararlı script enjekte edilmesi) ile çalınabilir. BFF'nin amacı token'ı sunucuda tutmak.
Claim adlarını varsaymak. ASP.NET Core varsayılan olarak sub gibi claim'leri uzun XML adlarına çevirir; MapInboundClaims = false ile Keycloak'un verdiği adlar korunuyor. Rol claim'inin adı da RoleClaimType ile açıkça söylenmeli, yoksa RequireRole hiçbir rolü bulamaz.
ID token'ı API'ye göndermek. ID token istemci için; audience'ı BFF'nin kendisi. API'ler access token bekler.
Küçük Bir Deneme
★☆☆ Ada ile giriş yapıp beş dakikadan fazla bekle ve GET /api/orders'ı tekrar çağır. Ordering'in log'unda hangi hata görünüyor? Cookie hâlâ geçerliyken neden 401 alıyorsun?
★★☆ Catalog'un okuma endpoint'lerine giriş yapmış kullanıcıya özel bir alan ekle (ör. "favorilerinde mi"). Cevap kullanıcıya göre değişince 03'teki output cache ne yapmalı? Cache key'ine ne eklemen gerekir?
★★★ ITicketStore'u Redis ile yaz ve BFF'nin cookie'sini tek, küçük bir anahtara indir. Logout'tan sonra eski cookie'yi tekrar gönderdiğinde ne oluyor; parçalı cookie'de ne oluyordu?
Service'ler artık kimin çağırdığını biliyor, her biri kendi token'ını doğruluyor ve kimlik event'lerle akıyor. Bu seride bundan sonra Angular arayüzü, dağıtık trace'ler, testler, deploy ve geriye bakış var: bir sonraki yazıda Angular 22 uygulaması BFF'nin arkasına geliyor ve siparişin "onay bekleniyor"dan "onaylandı"ya geçişini kullanıcıya canlı gösteriyoruz.