API'yi Korumak: Access Token Doğrulama, Audience, Scope Policy'leri, Client Credentials ve DPoP
API, imzası geçerli her token'ı değil, yalnız kendi audience'ı için verilmiş ve gereken scope'u taşıyan token'ı kabul etmelidir. AddJwtBearer'ı Keycloak'a bağlıyor, scope'tan policy'ye eşliyor, çıkış yapan kullanıcının JWT'sinin neden hâlâ geçtiğini introspection ile gösteriyor, client credentials token'ını cache'leyen bir DelegatingHandler yazıyor ve DPoP ile çalınan token'ın işe yaramadığını beş senaryoda doğruluyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14, Microsoft.IdentityModel.JsonWebTokens 8.23.0 ve Keycloak 26.8.2 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore API'nin (Application Programming Interface) önünde AddJwtBearer var ve Keycloak'un imzaladığı her token'ı doğruluyor. Ada çıkış yaptı, Keycloak session'ı kapattı. Ada'nın beş dakika ömürlü access token'ı API'de hâlâ çalışır mı? İkinci soru: stok servisi her dakika 40 istek atıyor; kaç kez token istemeli? Üçüncüsü: bir proxy log'undan access token'ı kopyalayan biri onunla ne yapabilir?
Önceki iki yazıda client tarafındaydık: token almak, ID token'ı doğrulamak, session'ı bitirmek. Bu yazıda resource server'a, yani token'ı kabul eden API'ye geçiyoruz. JWT (JSON Web Token) doğrulamanın temeli (issuer, audience, süre, imza, algoritma) Web Güvenliği (OWASP Top 10) serisinin "Authentication Failures (2)" yazısında ve ASP.NET Core Web API serisinin "JWT ile Authentication" yazısında anlatıldı; orada token'ı uygulama kendisi üretiyordu. Burada üreten bir authorization server, API ise yalnız tüketici. Lab'da API http://localhost:19213'te, Keycloak her zamanki yerinde.
AddJwtBearer ve Authority
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"]; // discovery + JWKS buradan
options.Audience = "bookstore-api";
options.RequireHttpsMetadata = false; // yalnız yerel lab
options.MapInboundClaims = false;
options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.RsaSha256];
options.TokenValidationParameters.NameClaimType = "preferred_username";
// Events: DPoP bölümünde
});
Authority verince handler issuer'ı ve imza key'lerini discovery dokümanından alıyor; secseries'teki gibi IssuerSigningKey'i elle vermiyoruz. Keycloak key'ini döndürdüğünde handler bilinmeyen kid'i görünce konfigürasyonu yeniden çekiyor. Audience tek satır ama en önemli satır. ValidAlgorithms ile Keycloak'un discovery'de ilan ettiği on üç algoritmadan yalnız kullandığımızı kabul ediyoruz. MapInboundClaims = false'ın gerekçesi bir önceki yazıdaydı: token'daki claim adı neyse kodda da o.
Token'sız ve audience'ı olmayan bir token'la istek:
no token:
401 WWW-Authenticate: Bearer
no scope token:
401 WWW-Authenticate: Bearer error="invalid_token", error_description="The audience 'empty' is invalid"
İkinci token stok servisinin client credentials ile hiç scope istemeden aldığı token. İmzası geçerli, issuer doğru, süresi dolmamış; ama içinde aud yok ve API onu reddediyor. WWW-Authenticate header'ındaki açıklama geliştirmede çok işe yarıyor; production'da hata ayrıntısını dışarı vermek istemezsen IncludeErrorDetails = false.
Audience: Her API'nin Kendi Kapısı
Audience doğrulaması olmasaydı ne olurdu? Bookstore'un bir de raporlama API'si olduğunu düşün; Keycloak aynı realm'de ona da token basıyor. Raporlama API'si için alınmış bir token, imzası aynı key'le atıldığı için Bookstore API'den geçerdi. Daha kötüsü, raporlama API'si o token'ı kendi çağrısında Bookstore API'ye iletebilir; token bir API'den diğerine "taşınabilir" hâle gelir. RFC (Request for Comments) 9700 bu yüzden token'ların belli resource server'lara kısıtlanmasını ve resource server'ın yanlış audience'lı token'ı reddetmesini istiyor.
Lab'da audience'ı Keycloak'un audience mapper'ıyla veriyoruz: books.read, orders.read ve orders.write client scope'larının her biri token'a aud: bookstore-api ekliyor. Client bu scope'lardan birini istemezse token'da Bookstore API'nin audience'ı hiç yok. Ada yalnız openid scope'uyla giriş yapıp API'ye gittiğinde:
ada openid only -> books:
401 WWW-Authenticate: Bearer error="invalid_token", error_description="The audience '(null)' is invalid"
RFC 8707 (Resource Indicators for OAuth 2.0) aynı problemi protokol düzeyinde çözüyor: client token isterken resource=https://api.bookstore.example parametresiyle token'ın hangi API için olduğunu söylüyor, authorization server aud'u ona göre koyuyor. Destekleyen bir authorization server'da birden çok API'ye ayrı ayrı, dar audience'lı token almak böylece client'ın elinde. Hangi yolla olursa olsun kural aynı: bir token, bir API.
Scope'tan Policy'ye
Keycloak scope'ları tek bir scope claim'inde, boşlukla ayrılmış olarak gönderiyor. ASP.NET Core'un RequireClaim("scope", "books.read")'i claim değerinin tamamını karşılaştırdığı için "openid profile books.read" gibi bir değerde işe yaramıyor. Küçük bir extension method yeterli:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("books.read", policy => policy.RequireScope("books.read"))
.AddPolicy("orders.read", policy => policy.RequireScope("orders.read").RequireEndUser());
app.MapGet("/books", () => new[] { "Dune", "Solaris", "Neuromancer" })
.RequireAuthorization("books.read");
app.MapGet("/orders/mine", (ClaimsPrincipal user) => new
{
Customer = user.Identity!.Name,
Client = user.FindFirstValue("azp")
}).RequireAuthorization("orders.read");
static class ScopePolicies
{
// Keycloak scope'ları tek bir claim'de, boşlukla ayrılmış olarak gönderir
public static AuthorizationPolicyBuilder RequireScope(this AuthorizationPolicyBuilder policy, string scope) =>
policy.RequireAuthenticatedUser().RequireAssertion(context =>
context.User.FindFirstValue("scope")?.Split(' ').Contains(scope) == true);
// Keycloak service account token'larında client_id claim'i var; son kullanıcı token'ında yok
public static AuthorizationPolicyBuilder RequireEndUser(this AuthorizationPolicyBuilder policy) =>
policy.RequireAssertion(context => !context.User.HasClaim(c => c.Type == "client_id"));
}
Bazı authorization server'lar scope'u bir JSON (JavaScript Object Notation) array'i olarak ya da scp adlı claim'de gönderiyor (Microsoft Entra ID scp kullanıyor); extension method'u kendi sunucuna göre yaz ve bir testle sabitle. Policy'lerin iç mekanizması (requirement, handler) ilerideki "ASP.NET Core Güvenliği" serisinin 01. yazısının konusu; burada yalnız eşlemeye bakıyoruz.
books.read:
200 ["Dune","Solaris","Neuromancer"]
books.read -> orders:
403
worker orders.read -> orders:
403
ada orders.read -> orders:
200 {"customer":"ada@bookstore.test","client":"bookstore-spa"}
Üçüncü satır önemli. Stok servisinin token'ında orders.read scope'u var, ama /orders/mine "benim siparişlerim" demek ve bir makinenin "benim"i yok. RequireEndUser olmadan endpoint service account'un sub'ıyla çalışırdı. Dördüncü satır da scope'un ne olmadığını gösteriyor: orders.read Ada'ya Grace'in siparişlerini okuma hakkı vermiyor; endpoint yalnız token'daki kullanıcının siparişlerini döndürmeli. Scope client'ın vekâletinin sınırı, kullanıcının yetkisi ayrı bir katman; 16. yazının konusu.
Opaque Token ve Introspection
JWT'nin avantajı API'nin token'ı authorization server'a sormadan, kendi başına doğrulayabilmesi. Dezavantajı da aynı cümle. Önce Tahmin Et'in ilk sorusu:
--- introspection
{
"active": true,
"client_id": "bookstore-spa",
"username": "ada@bookstore.test",
"scope": "openid profile orders.read email",
"exp": 1794117144
}
logout (revoke session): 204
{"active":false}
API with same JWT after logout:
200 {"customer":"ada@bookstore.test","client":"bookstore-spa"}
Ada çıkış yaptı, Keycloak'un introspection endpoint'i (RFC 7662, OAuth 2.0 Token Introspection) token'ın artık aktif olmadığını söylüyor; ama API aynı token'ı kabul etmeye devam ediyor, çünkü imza hâlâ geçerli ve exp gelmedi. JWT iptal edilemez; yalnız süresi dolar. Bu yüzden access token ömrü kısa (lab'da 300 saniye) ve uzun yaşayan şey refresh token; 09. yazıda onu iptal edilebilir kurmuştuk.
Gerçek zamanlı iptal gerekiyorsa iki yol var. Birincisi opaque token ve her istekte introspection: authorization server'a bir ağ çağrısı, ona bağımlılık ve gecikme demek; sonucu kısa süre cache'lemek bu maliyeti düşürür ama iptal gecikmesini geri getirir. İkincisi JWT'yi bırakıp hassas işlemlerde (ödeme, adres değişikliği) introspection'ı ek bir kontrol olarak çağırmak. Introspection çağrısı da kimlik doğrulamalı olmalı; lab'da API kendi client'ı bookstore-api ile Basic authentication yapıyor.
Makineler Arası: Token'ı Cache'leyen DelegatingHandler
Stok servisi API'ye IHttpClientFactory ile çağrı yapıyor. Token'ı her istekte almak 10. yazıda gördüğümüz gibi authorization server'a gereksiz yük. Token'ı cache'leyen bir DelegatingHandler:
// Singleton: token'ı cache'ler, süresi bitmeden 30 saniye önce yeniler; eşzamanlı istekler tek token isteği paylaşır
public sealed class ClientCredentialsTokenProvider(
IHttpClientFactory factory, ClientCredentialsOptions options, TimeProvider clock)
{
private static readonly TimeSpan RefreshBefore = TimeSpan.FromSeconds(30);
private readonly SemaphoreSlim _lock = new(1, 1);
private (string Token, DateTimeOffset ExpiresAt)? _cached;
public int TokenRequests { get; private set; }
public void Invalidate() => _cached = null;
public async Task<string> GetTokenAsync(CancellationToken ct)
{
if (_cached is { } hit && hit.ExpiresAt - RefreshBefore > clock.GetUtcNow())
{
return hit.Token;
}
await _lock.WaitAsync(ct);
try
{
if (_cached is { } again && again.ExpiresAt - RefreshBefore > clock.GetUtcNow())
{
return again.Token;
}
using HttpResponseMessage response = await factory.CreateClient("token").PostAsync(options.TokenEndpoint,
new FormUrlEncodedContent(
[
new("grant_type", "client_credentials"),
new("client_id", options.ClientId),
new("client_secret", options.ClientSecret),
new("scope", options.Scope)
]), ct);
response.EnsureSuccessStatusCode();
TokenResponse token = (await response.Content.ReadFromJsonAsync<TokenResponse>(ct))!;
TokenRequests++;
_cached = (token.AccessToken, clock.GetUtcNow().AddSeconds(token.ExpiresIn));
return token.AccessToken;
}
finally
{
_lock.Release();
}
}
private sealed record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
}
// Handler'ın kendisi durumsuz: IHttpClientFactory onu handler ömrü dolunca yeniden oluşturur
public sealed class ClientCredentialsHandler(ClientCredentialsTokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await tokens.GetTokenAsync(ct));
HttpResponseMessage response = await base.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
tokens.Invalidate(); // key rotation ya da iptal: bir sonraki istek yeni token alır
}
return response;
}
}
İlk taslakta cache'i handler'ın içine koymuştum. Sorun: IHttpClientFactory handler zincirini handler ömrü dolunca (varsayılan 2 dakika) yeniden kuruyor ve transient handler'daki cache de onunla gidiyor. Cache'i singleton bir provider'a taşıyınca handler durumsuz kaldı. Kayıt ve deneme:
services.AddSingleton<ClientCredentialsTokenProvider>();
services.AddTransient<ClientCredentialsHandler>();
services.AddHttpClient("token");
services.AddHttpClient("bookstore-api", client => client.BaseAddress = new Uri(Api))
.AddHttpMessageHandler<ClientCredentialsHandler>();
// 20 eşzamanlı çağrı, sonra 20 ardışık çağrı
HttpClient api = factory.CreateClient("bookstore-api");
HttpResponseMessage[] parallel = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => api.GetAsync("/books")));
for (int i = 0; i < 20; i++)
{
using HttpResponseMessage response = await factory.CreateClient("bookstore-api").GetAsync("/books");
response.EnsureSuccessStatusCode();
}
Console.WriteLine($"40 calls, all 200: {parallel.All(r => r.IsSuccessStatusCode)}, token requests: {tokens.TokenRequests}");
$ dotnet run
40 calls, all 200: True, token requests: 1
Kırk çağrı, bir token isteği; aynı anda gelen yirmi çağrı da SemaphoreSlim'in arkasında tek isteği paylaştı. Bunu kendin yazmak istemiyorsan Duende.AccessTokenManagement (4.3.0, Apache-2.0 lisanslı) aynı işi client credentials ve kullanıcı token'ları için yapıyor; ayrıca DPoP proof'u da üretebiliyor.
Sender-Constrained Token: DPoP
Bearer token adı üstünde: taşıyan kullanır. Önce Tahmin Et'in üçüncü sorusunun cevabı bu; kopyalanan token süresi bitene kadar her yerden çalışır. DPoP (Demonstrating Proof of Possession, RFC 9449) token'ı client'ın elindeki bir private key'e bağlıyor. Client her istekte o key'le imzaladığı kısa bir JWT, yani proof gönderiyor: hangi HTTP (Hypertext Transfer Protocol) method'u (htm), hangi adres (htu), ne zaman (iat), tek kullanımlık bir kimlik (jti) ve access token'ın hash'i (ath). Authorization server token'ın cnf.jkt claim'ine key'in thumbprint'ini yazıyor; API proof'un o key'le imzalandığını kontrol ediyor. Token'ı çalan, key'i çalmadan hiçbir şey yapamıyor.
Keycloak'ta bir client için DPoP'u zorunlu yapmak tek attribute: "dpop.bound.access.tokens": "true". Lab'da bunu yeni bir client'a, bookstore-dpop-worker'a verdim. Proof'suz token isteği:
{"error":"invalid_request","error_description":"DPoP proof is missing"}
Client tarafında proof üretmek JsonWebTokenHandler ile birkaç satır:
static string CreateProof(ECDsa key, string method, string url, string? accessToken)
{
ECParameters p = key.ExportParameters(includePrivateParameters: false);
Dictionary<string, object> jwk = new()
{
["kty"] = "EC",
["crv"] = "P-256",
["x"] = Base64UrlEncoder.Encode(p.Q.X!),
["y"] = Base64UrlEncoder.Encode(p.Q.Y!)
};
Dictionary<string, object> claims = new()
{
["htm"] = method,
["htu"] = url,
["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
["jti"] = Guid.NewGuid().ToString()
};
if (accessToken is not null)
{
// ath: proof'u belirli bir access token'a bağlar
claims["ath"] = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
}
return new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = false }.CreateToken(new SecurityTokenDescriptor
{
TokenType = "dpop+jwt",
AdditionalHeaderClaims = new Dictionary<string, object> { ["jwk"] = jwk },
Claims = claims,
SigningCredentials = new SigningCredentials(new ECDsaSecurityKey(key), SecurityAlgorithms.EcdsaSha256)
});
}
Public key proof'un header'ında (jwk) gidiyor; private key client'tan hiç çıkmıyor. Token isteğine bu proof eklenince Keycloak token_type'ı DPoP olarak döndü ve token'a key'in thumbprint'ini yazdı:
token_type=DPoP cnf={"jkt":"dJnKHIhy6mRXrmXl8_EciyPO3eD8U77cl_zG4Fy50Yo","kc-jkt-type":"DPoP"}
API tarafı daha zor: ASP.NET Core 10'un JwtBearer handler'ında yerleşik DPoP doğrulaması yok. Duende'nin Duende.AspNetCore.Authentication.JwtBearer paketi bunu ekliyor (NuGet metadata'sında açık bir lisans ifadesi yok, kullanmadan önce koşullarını kontrol et). Mekaniği görmek için RFC 9449'un 4.3 ve 7.1 bölümlerindeki kontrolleri en küçük hâliyle kendim yazdım. JwtBearer'a iki event ekliyorum: OnMessageReceived Authorization: DPoP <token> şemasını okuyor, OnTokenValidated proof'u doğruluyor:
options.Events = new JwtBearerEvents
{
// "Authorization: DPoP <token>" şemasını da oku
OnMessageReceived = context =>
{
string? header = context.Request.Headers.Authorization;
if (header?.StartsWith("DPoP ", StringComparison.Ordinal) == true)
{
context.Token = header["DPoP ".Length..];
}
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
DPoPProofValidator validator = context.HttpContext.RequestServices.GetRequiredService<DPoPProofValidator>();
string? error = await validator.ValidateAsync(context.HttpContext, context.Principal!);
if (error is not null)
{
context.Fail(error);
}
}
};
// RFC 9449 bölüm 4.3 ve 7.1'in en küçük hâli: proof imzası, htm/htu, iat, jti tekrarı, ath ve cnf.jkt eşleşmesi
public sealed class DPoPProofValidator(IMemoryCache replayCache, TimeProvider clock)
{
private static readonly TimeSpan MaxAge = TimeSpan.FromSeconds(60);
public async Task<string?> ValidateAsync(HttpContext http, ClaimsPrincipal principal)
{
string? authorization = http.Request.Headers.Authorization;
bool dpopScheme = authorization?.StartsWith("DPoP ", StringComparison.Ordinal) == true;
string? cnf = principal.FindFirstValue("cnf");
if (cnf is null)
{
// Bağlı olmayan token DPoP şemasıyla gelemez (downgrade'in tersi de reddedilir)
return dpopScheme ? "DPoP scheme used with an unbound token" : null;
}
if (!dpopScheme)
{
return "DPoP-bound token sent as Bearer";
}
string[] proofs = http.Request.Headers["DPoP"].ToArray()!;
if (proofs.Length != 1)
{
return "exactly one DPoP header required";
}
JsonWebToken proof = new(proofs[0]);
if (!proof.TryGetHeaderValue("jwk", out JsonElement jwkJson))
{
return "proof has no jwk header";
}
JsonWebKey jwk = new(jwkJson.GetRawText());
if (jwk.HasPrivateKey)
{
return "proof jwk contains a private key";
}
TokenValidationResult result = await new JsonWebTokenHandler().ValidateTokenAsync(proofs[0], new TokenValidationParameters
{
IssuerSigningKey = jwk,
ValidTypes = ["dpop+jwt"],
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.RsaSha256],
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = false,
RequireExpirationTime = false
});
if (!result.IsValid)
{
return $"proof signature invalid: {result.Exception?.GetType().Name}";
}
string htu = $"{http.Request.Scheme}://{http.Request.Host}{http.Request.PathBase}{http.Request.Path}";
if (proof.GetPayloadValue<string>("htm") != http.Request.Method || proof.GetPayloadValue<string>("htu") != htu)
{
return "proof htm/htu mismatch";
}
DateTimeOffset issuedAt = DateTimeOffset.FromUnixTimeSeconds(proof.GetPayloadValue<long>("iat"));
if ((clock.GetUtcNow() - issuedAt).Duration() > MaxAge)
{
return "proof too old";
}
string jti = proof.GetPayloadValue<string>("jti");
if (!replayCache.TryGetValue(jti, out _))
{
replayCache.Set(jti, true, MaxAge * 2);
}
else
{
return "proof replayed";
}
string accessToken = authorization!["DPoP ".Length..];
string ath = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
if (proof.GetPayloadValue<string>("ath") != ath)
{
return "proof ath does not match the access token";
}
string jkt = Base64UrlEncoder.Encode(jwk.ComputeJwkThumbprint());
string boundJkt = JsonDocument.Parse(cnf).RootElement.GetProperty("jkt").GetString()!;
return jkt == boundJkt ? null : "proof key does not match cnf.jkt";
}
}
Beş senaryo, kendi lab'ımda, kendi token'ımla:
$ dotnet run -- dpop
token_type=DPoP cnf={"jkt":"dJnKHIhy6mRXrmXl8_EciyPO3eD8U77cl_zG4Fy50Yo","kc-jkt-type":"DPoP"}
DPoP + valid proof -> 200
same proof again -> 401
stolen token as Bearer -> 401
proof from another key -> 401
proof for another URL -> 401
Bearer was not authenticated. Failure message: proof replayed
Bearer was not authenticated. Failure message: DPoP-bound token sent as Bearer
Bearer was not authenticated. Failure message: proof key does not match cnf.jkt
Bearer was not authenticated. Failure message: proof htm/htu mismatch
En kritik satır üçüncüsü: token'ı çalan biri onu klasik Bearer olarak göndermeyi deniyor ve API, token'ın bağlı olduğunu cnf'den bildiği için reddediyor. Bu kontrolü atlarsan DPoP'un bütün faydası gider. Dördüncü satır kendi key'iyle proof üreten saldırgan, beşincisi başka bir endpoint için üretilmiş proof'u tekrar kullanmaya çalışan biri.
mTLS (mutual TLS (Transport Layer Security), RFC 8705) aynı fikri TLS katmanında uyguluyor: token client sertifikasının thumbprint'ine bağlanıyor ve API bağlantıdaki sertifikaya bakıyor. Sunucudan sunucuya trafikte, sertifika altyapısı zaten olan kurumlarda pratik; tarayıcıda ve TLS'i önde sonlandıran proxy'lerin arkasında zor. DPoP uygulama katmanında çalıştığı için bu kısıtlar yok.
Üretimde Dikkat
DPoP'un jti tekrar kontrolü lab'da IMemoryCache ile; birden fazla API instance'ı varsa paylaşılan bir cache gerekir, yoksa bir instance'ta kullanılan proof diğerinde tekrar kabul edilir. htu karşılaştırması reverse proxy arkasında bozulur: API kendini http://internal:8080 olarak görürken client https://api.bookstore.example'a proof imzalar. Forwarded header'ları doğru ayarla ya da beklenen dış adresi configuration'dan oku. RFC 9449 hata cevabında WWW-Authenticate: DPoP şemasını ve gerekirse sunucunun ürettiği bir nonce'u da tanımlıyor; benim örneğim bunları yapmıyor.
Audience'ı API başına ayır ve ortamlar arasında paylaşma; test ortamının token'ı production API'de geçmemeli. Bunun en kolay yolu ortam başına ayrı realm ya da tenant. Introspection kullanıyorsan sonucunu cache'le ama süresini iptal gecikmesine tahammülünle sınırla; authorization server'ın erişilemediği durumda API'nin kapıyı kapatıp kapatmayacağına (fail closed) baştan karar ver.
Keycloak log'unda her token isteğinde gördüğüm bir uyarı da var: Client 'bookstore-web' ... has 'Full scope allowed' switch enabled. This client switch is deprecated. "Full scope allowed" açıkken client'ın token'ına kullanıcının bütün rolleri giriyor; token'ı client'ın gerçekten ihtiyaç duyduğu rollerle sınırlamak için kapatılması gerekiyor. Lab client'larımda varsayılan olarak açık kaldı.
Sık Düşülen Tuzaklar
Birincisi, tüm API'lerin aynı audience'ı kabul etmesi ya da ValidateAudience = false. İkincisi, scope'u kullanıcı yetkisi sanmak; yukarıdaki orders.read örneği. Üçüncüsü, RequireClaim("scope", ...)'i boşlukla ayrılmış bir değere uygulamak; hiçbir zaman eşleşmez ve "neden hep 403" diye saatler gider. Dördüncüsü, çıkış yapan kullanıcının JWT'sinin hemen geçersiz olduğunu varsaymak.
Beşincisi, client credentials token'ını her istekte almak ya da cache'i transient bir handler'da tutmak. Altıncısı, DPoP kurup bağlı token'ın Bearer olarak gelmesine izin vermek. Yedincisi, ID token'ı ya da başka bir API'nin token'ını kabul etmek; hepsinin çaresi aynı: audience.
Küçük Bir Deneme
★☆☆ API'de options.Audience satırını sil ve "no scope token" isteğini tekrarla. Cevap ne oldu, neden? Sonra satırı geri koy.
★★☆ ClientCredentialsTokenProvider'a FakeTimeProvider ile bir birim testi yaz: token 300 saniye ömürlüyken 269. saniyede cache'ten, 271. saniyede yeni istekle geldiğini doğrula.
★★★ DPoPProofValidator'a RFC 9449'un sunucu nonce'unu ekle: API ilk istekte 401 ile birlikte DPoP-Nonce header'ı dönsün, client nonce'u proof'a koyup tekrar denesin. Nonce'un saat farkı ve önceden üretilmiş proof saldırısına karşı neyi değiştirdiğini açıkla.
API artık hangi token'ı, hangi client'tan, hangi kullanıcı için kabul ettiğini biliyor. Bir sonraki yazıda tarayıcıya dönüyoruz: SPA'nın (Single-Page Application) token'ı hiç görmediği BFF (Backend for Frontend) pattern'i, RFC 10017'nin önerdiği mimari ve YARP (Yet Another Reverse Proxy) ile API'ye giden isteğe token'ı sunucuda eklemek.