OAuth 2.0'ın Mantığı: Roller, Grant Type'lar, Scope'lar ve RFC 9700 ile OAuth 2.1'in Getirdikleri
OAuth 2.0 bir giriş protokolü değil, yetki devri protokolüdür. Dört rolü ve front/back channel ayrımını, confidential ve public client farkını, authorization code, client credentials ve device grant'i Keycloak lab'ında .NET 10 koduyla çalıştırıyor; implicit ve password grant'in neden kaldırıldığını, RFC 9700'ün ve OAuth 2.1 draft'ının getirdiklerini ve access token'ın neden giriş kanıtı olmadığını gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Keycloak 26.8.2 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un stok servisi gece çalışıp kitap kataloğunu okuyacak. Ortada giriş yapan bir kullanıcı yok. Servis token'ı nereden alır, token'ın sub claim'inde kim yazar? Bir de şunu düşün: akıllı televizyondaki Bookstore uygulamasında klavye yok; kullanıcı parolasını kumandayla mı girecek? Son soru: authorization server'da hâlâ açık duran implicit grant'i kullanan eski bir SPA'yı (Single-Page Application) taşımak zorunda mısın, yoksa "çalışıyorsa dokunma" mı?
Bu üç sorunun cevabı aynı yerden çıkıyor: OAuth 2.0 bir giriş protokolü değil, bir yetki devri (delegation) protokolü. Kim kimin adına, hangi kaynağa, ne kadar süre erişecek; OAuth bunu standartlaştırıyor. Seriye kadar cookie session (02), ASP.NET Core Identity (03, 04), MFA (Multi-Factor Authentication, 05), passkey'ler (06, 07), JWT (JSON Web Token, 08) ve refresh token (09) ile kendi uygulamamızın içinde kaldık. Bu yazıyla Blok D'ye, yani kimliği ve yetkiyi uygulamanın dışındaki bir authorization server'a devrettiğimiz dünyaya geçiyoruz. Lab'ın authorization server'ı Docker'da çalışan Keycloak; her çıktı o lab'dan.
Dört Rol ve İki Kanal
OAuth 2.0'ı tanımlayan belge RFC (Request for Comments) 6749, The OAuth 2.0 Authorization Framework; dört rol tanımlar. Resource owner, kaynağın sahibi; çoğu zaman kullanıcı, yani Ada. Client, kaynağa erişmek isteyen uygulama; Bookstore'un web uygulaması, televizyon uygulaması ya da stok servisi. Authorization server, resource owner'ın onayını alıp token basan sunucu; bizim lab'da Keycloak. Resource server, token'ı kabul edip veriyi döndüren API (Application Programming Interface); Bookstore API.

Roller kadar önemli olan, mesajların hangi kanaldan gittiği. Front channel, tarayıcı üzerinden giden redirect'ler: authorization request ve authorization response tarayıcının adres çubuğundan geçer, tarayıcı geçmişine, log'lara, Referer header'ına düşebilir. Back channel ise client'ın authorization server'la doğrudan, TLS (Transport Layer Security) üzerinden konuştuğu HTTP (Hypertext Transfer Protocol) isteği: token endpoint'ine yapılan POST. OAuth'un modern hâlinin neredeyse tamamı tek bir kurala dayanıyor: değerli olan şey (access token, refresh token) front channel'dan geçmesin.
Client'lar da ikiye ayrılır. Confidential client, bir secret'ı ya da private key'i güvenle saklayabilen client'tır: sunucuda çalışan bir ASP.NET Core uygulaması, bir worker. Public client bunu yapamaz: tarayıcıda çalışan Angular uygulaması, mobil uygulama, televizyon uygulaması. Kodu kullanıcının cihazında olan her şeyin içindeki secret, secret değildir. Keycloak'ta bu ayrım client ayarındaki "Client authentication" anahtarı; lab'daki stok servisi şöyle tanımlı:
# kc-setup.sh içinden: makineler arası client (confidential, yalnız client credentials)
$K create clients -r bookstore -f - <<'EOF'
{ "clientId": "bookstore-inventory-worker", "enabled": true, "publicClient": false, "secret": "dev-only-inventory-worker-secret",
"standardFlowEnabled": false, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true, "optionalClientScopes": ["books.read", "orders.read"] }
EOF
Her client'ta yalnız gerçekten kullandığı grant açık. standardFlowEnabled authorization code, implicitFlowEnabled implicit, directAccessGrantsEnabled password grant, serviceAccountsEnabled client credentials demek. Secret'ın dev-only- ile başlaması bilinçli: bu değer yalnız yerel lab'da yaşıyor.
Authorization Code: Tarayıcı Yalnızca Kurye
Kullanıcının olduğu her senaryoda kullanacağın grant authorization code. Akış kabaca şöyle: client tarayıcıyı authorization server'a yönlendirir, kullanıcı orada giriş yapar ve onay verir, authorization server tarayıcıyı tek kullanımlık, kısa ömürlü bir code ile client'a geri gönderir, client da bu code'u back channel'dan token'la değiştirir. Front channel'dan geçen tek değerli şey code; o da tek başına işe yaramıyor, çünkü değiştirmek için ya client secret'ı ya da PKCE'nin (Proof Key for Code Exchange) code_verifier'ı gerekiyor.
Lab'daki public client bookstore-spa'ya, kayıtlı redirect URI'si (Uniform Resource Identifier) doğru ama PKCE'siz bir istek gönderince Keycloak bunu daha ilk adımda reddediyor:
$ curl -s -o /dev/null -w "%{http_code}\n%{redirect_url}\n" \
"$B/auth?client_id=bookstore-spa&response_type=code&redirect_uri=http://localhost:19211/callback&scope=openid&state=af0ifjsldkj"
302
http://localhost:19211/callback?error=invalid_request&error_description=Missing+parameter%3A+code_challenge_method&state=af0ifjsldkj&iss=http%3A%2F%2Flocalhost%3A19200%2Frealms%2Fbookstore
Hata bile redirect ile, front channel'dan dönüyor; state geri geliyor ve sonunda bir iss parametresi var. O iss, RFC 9207'nin mix-up saldırısına karşı eklediği issuer bilgisi. PKCE'nin, state'in ve nonce'un ayrıntısını bir sonraki yazıda HTTP trafiğini satır satır okuyarak işleyeceğiz; burada grant'in mantığını tutman yeterli.
Client Credentials: Kullanıcısız Token
Stok servisi gibi bir makinenin kendi adına çağrı yaptığı senaryoda resource owner client'ın kendisi. Ortada redirect, tarayıcı, onay ekranı yok; client kendini doğrular ve token alır. .NET 10'da bunu kütüphanesiz yazmak birkaç satır:
async Task ClientCredentialsAsync()
{
// Kullanıcı yok: client kendi adına, kendi secret'ıyla token alır (back channel)
using HttpResponseMessage response = await http.PostAsync($"{Realm}/token", new FormUrlEncodedContent(
[
new("grant_type", "client_credentials"),
new("client_id", "bookstore-inventory-worker"),
new("client_secret", Environment.GetEnvironmentVariable("WORKER_SECRET") ?? ""),
new("scope", "books.read")
]));
TokenResponse? token = await response.Content.ReadFromJsonAsync<TokenResponse>();
Console.WriteLine($"{(int)response.StatusCode} token_type={token?.TokenType} expires_in={token?.ExpiresIn} scope={token?.Scope}");
}
$ dotnet run -- client-credentials
200 token_type=Bearer expires_in=300 scope=books.read
Token'ın payload'ına bakınca (yalnız lab'da, kendi token'ımızı okumak için) iki şey göze çarpıyor:
{
"exp": 1794115682,
"iat": 1794115382,
"jti": "trrtcc:e3dc0c70-62d8-bab3-70b1-08a435e58ff3",
"iss": "http://localhost:19200/realms/bookstore",
"aud": "bookstore-api",
"sub": "ed0f36d8-8358-45d0-8314-adce15677661",
"typ": "Bearer",
"azp": "bookstore-inventory-worker",
"scope": "books.read",
"clientHost": "172.66.2.5",
"clientAddress": "172.66.2.5",
"client_id": "bookstore-inventory-worker"
}
Birincisi sub: bir kullanıcının değil, Keycloak'un bu client için açtığı service account'un kimliği. API tarafında "bu isteği bir insan mı, bir makine mi yaptı?" sorusunu client_id/azp ile ve kullanıcıya özgü claim'lerin yokluğuyla cevaplarsın. İkincisi aud: books.read scope'una bağladığımız audience mapper sayesinde token yalnız bookstore-api için geçerli. Scope'ları kendi tanımladığımız client scope'lar; her biri token'a hem scope'u hem audience'ı ekliyor.
Scope'un ne olduğuna burada netlik getirelim. Scope, client'ın istediği ve aldığı vekâletin sınırı: "bu token'la katalog okunabilir". Kullanıcının ne yapabileceğini söylemez. İstenmemiş ya da client'a tanımlanmamış bir scope istersen:
$ curl -s -X POST $B/token -d grant_type=client_credentials -d client_id=bookstore-inventory-worker \
-d client_secret=$WORKER_SECRET -d scope=orders.write
{"error":"invalid_scope","error_description":"Invalid scopes: orders.write"}
$ curl -s -X POST $B/token -d grant_type=client_credentials -d client_id=bookstore-spa
{"error":"unauthorized_client","error_description":"Public client not allowed to retrieve service account"}
İkinci satır da öğretici: public client'ın client credentials kullanması anlamsız, çünkü kanıtlayacağı bir secret yok. Makineler arası kimliğin daha geniş hâli (API key'ler, workload identity) ilerideki "API Güvenliği" serisinin 03. yazısının konusu; token'ı cache'leyip yenileyen bir DelegatingHandler'ı da 13. yazıda yazacağız.
Device Authorization: Klavyesi Olmayan Cihaz
Televizyon uygulaması public client ve kullanıcıdan parola istemesi kabul edilemez. RFC 8628 (OAuth 2.0 Device Authorization Grant) bunun için var: cihaz bir user_code ister ve ekrana yazar, kullanıcı telefonunda ya da bilgisayarında doğrulama adresini açıp kodu girer ve onaylar, cihaz bu sırada token endpoint'ini belli aralıklarla yoklar.
async Task DeviceFlowAsync()
{
// 1) Cihaz, kullanıcıya gösterilecek kısa kodu ister
using HttpResponseMessage start = await http.PostAsync($"{Realm}/auth/device", new FormUrlEncodedContent(
[
new("client_id", "bookstore-tv"),
new("scope", "orders.read")
]));
DeviceAuthorization device = (await start.Content.ReadFromJsonAsync<DeviceAuthorization>())!;
Console.WriteLine($"Open {device.VerificationUri} and enter {device.UserCode}");
// 2) Kullanıcı başka bir cihazda onaylayana kadar token endpoint'i yoklanır
TimeSpan interval = TimeSpan.FromSeconds(device.Interval);
Stopwatch clock = Stopwatch.StartNew();
while (clock.Elapsed < TimeSpan.FromSeconds(device.ExpiresIn))
{
await Task.Delay(interval);
using HttpResponseMessage poll = await http.PostAsync($"{Realm}/token", new FormUrlEncodedContent(
[
new("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
new("client_id", "bookstore-tv"),
new("device_code", device.DeviceCode)
]));
if (poll.IsSuccessStatusCode)
{
TokenResponse token = (await poll.Content.ReadFromJsonAsync<TokenResponse>())!;
Console.WriteLine($"[{clock.Elapsed.TotalSeconds,4:0}s] {(int)poll.StatusCode} token_type={token.TokenType} scope={token.Scope}");
return;
}
OAuthError error = (await poll.Content.ReadFromJsonAsync<OAuthError>())!;
Console.WriteLine($"[{clock.Elapsed.TotalSeconds,4:0}s] {(int)poll.StatusCode} {error.Error}");
switch (error.Error)
{
case "authorization_pending":
continue;
case "slow_down":
// RFC 8628: slow_down gelirse aralık 5 saniye uzatılır
interval += TimeSpan.FromSeconds(5);
continue;
default:
return; // access_denied, expired_token ...
}
}
}
Uygulamayı başlatıp ikinci bir terminalde Ada olarak doğrulama sayfasına giriş yaptım ve onay ekranında kabul ettim:
$ dotnet run -- device
Open http://localhost:19200/realms/bookstore/device and enter BRAM-PEDM
[ 5s] 400 authorization_pending
[ 10s] 400 authorization_pending
[ 15s] 200 token_type=Bearer scope=orders.read
Keycloak'un döndüğü interval 5 saniye, expires_in 600 saniyeydi. Bu grant'in zayıf noktası teknik değil, insani: saldırgan kendi başlattığı akışın user_code'unu kurbana "şu kodu gir" diye gönderebilir. Onay ekranında hangi uygulamanın, hangi yetkiyi istediğini açıkça göstermek ve kodun ömrünü kısa tutmak bu yüzden önemli. Device grant'i yalnız gerçekten giriş kısıtlı cihazlar için aç.
Kaldırılan Grant'ler: Implicit ve Password
Keycloak'un discovery dokümanındaki grant_types_supported listesinde implicit ve password hâlâ var; sunucu bunları destekliyor, ama lab'daki her client'ta kapalı. Kapalı olduğunda client'ın gördüğü:
$ curl -s -o /dev/null -w "%{http_code}\n%{redirect_url}\n" \
"$B/auth?client_id=bookstore-spa&response_type=token&redirect_uri=http://localhost:19211/callback&scope=openid&state=af0ifjsldkj&nonce=n-0S6"
302
http://localhost:19211/callback#error=unauthorized_client&error_description=Client+is+not+allowed+to+initiate+browser+login+with+given+response_type.+Implicit+flow+is+disabled+for+the+client.&state=af0ifjsldkj&iss=http%3A%2F%2Flocalhost%3A19200%2Frealms%2Fbookstore
$ curl -s -X POST $B/token -d grant_type=password -d client_id=bookstore-spa \
-d username=ada@bookstore.test --data-urlencode "password=$ADA_PASSWORD"
{"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}
Implicit grant'te (response_type=token) access token doğrudan authorization response'un içinde, URL'nin (Uniform Resource Locator) fragment'ında geliyordu. Hata cevabının da # ile başlayan fragment'ta gelmesi bu yüzden. Token front channel'dan geçiyor, tarayıcı geçmişine ve açık bir redirect'te başka bir siteye sızabiliyor; client'ın token'ı kimin için üretildiğini doğrulama imkânı yok ve token'ı belli bir göndericiye bağlamak mümkün değil. 2012'de tarayıcıdaki JavaScript başka origin'deki token endpoint'ine istek atamadığı için vardı; CORS (Cross-Origin Resource Sharing) ve PKCE geldikten sonra varlık nedeni kalmadı.
Password grant, yani ROPC (Resource Owner Password Credentials), kullanıcının parolasını client'a veriyor. Bu, OAuth'un çözmek için var olduğu problemin ta kendisi: client parolayı görüyor, MFA ve passkey gibi her şey authorization server'ın ekranında yaşadığı için devre dışı kalıyor, phishing'e dayanıklı giriş imkânsız hâle geliyor. Bu yüzden "kendi mobil uygulamamız, güvenilir client" gerekçesi de geçerli değil.
RFC 9700 ve OAuth 2.1 Neyi Değiştiriyor?
RFC 6749 2012'den. Aradan geçen yılların dersleri Ocak 2025'te RFC 9700 (BCP 240, Best Current Practice for OAuth 2.0 Security) olarak yayımlandı. OAuth 2.1 ise bu dersleri ve ek RFC'leri tek bir çekirdek belgede topluyor; henüz RFC değil, IETF'te (Internet Engineering Task Force) draft: son revizyon 31 Ekim 2026 tarihli draft-ietf-oauth-v2-1-17. Yani "OAuth 2.1 uyumlu" diyen bir ürün, aslında bir draft'a uyduğunu söylüyor; pratikte RFC 9700'e uyan bir kurulum 2.1'in istediğini zaten büyük ölçüde karşılıyor.
| Konu | RFC 6749 (2012) | RFC 9700 / OAuth 2.1 draft |
|---|---|---|
| PKCE | Yok (sonradan RFC 7636) | Public client'ta zorunlu, confidential'da önerilir; method S256 |
| Redirect URI | Kayıtlı URI ile eşleşme gevşek | Birebir string eşleşmesi (loopback port hariç) |
| Implicit | Tanımlı | Kullanılmamalı, 2.1'de yok |
| Password (ROPC) | Tanımlı | Yasak, 2.1'de yok |
| Token tekrar kullanımı | Bearer token | Sender-constrained token önerilir (mTLS, DPoP) |
| Public client refresh token | Kural yok | Sender-constrained ya da rotation zorunlu |
| Audience | Tanımsız | Token belli resource server'lara kısıtlanmalı |
| Client authentication | Secret yaygın | Asymmetric yöntemler önerilir (private_key_jwt, mTLS) |
Tablodaki DPoP (Demonstrating Proof of Possession, RFC 9449) ve mTLS (mutual TLS, RFC 8705) token'ı onu alan client'ın bir anahtarına bağlıyor; çalınan token tek başına işe yaramıyor. 13. yazıda DPoP'u Keycloak'la deneyeceğiz. Refresh token rotation'ı ve reuse detection'ı 09. yazıda kendi elimizle kurmuştuk; RFC 9700'ün public client'lar için istediği tam olarak o.
OAuth Bir Authentication Protokolü Değildir
Bu cümle her OAuth sunumunda tekrarlanır ama sebebi pek anlatılmaz. Access token client için yazılmış bir mesaj değil; resource server için yazılmış bir yetki belgesi. Client'ın token'ın içini okuması OAuth'un sözleşmesinde yok; token opaque (anlamsız bir string) da olabilir. Client token'ı aldığında öğrendiği tek şey "bu token'la bir API'ye gidebilirim" oluyor. Kullanıcının kim olduğu, ne zaman ve nasıl giriş yaptığı, token'ın bu client için mi üretildiği OAuth'ta tanımlı değil.
Somut bir saldırı bunu gösterir: "Bookstore ile giriş yap" diyen bir uygulama, kendisine gelen access token'la /me endpoint'ini çağırıp dönen kullanıcıyı içeri alıyorsa, başka bir client için alınmış bir token'ı ona veren herkes o kullanıcı olarak giriş yapabilir. OIDC (OpenID Connect) tam bu boşluğu dolduruyor: client için yazılmış, aud'ı client olan, imzalı bir ID token ve nonce, auth_time, amr gibi giriş olayını anlatan claim'ler. 12. yazının konusu bu.
Üretimde Dikkat
Authorization server'da her client için yalnız kullandığı grant'leri aç ve bunu varsayılanlara bırakma. Lab'da kcadm ile hiçbir alan vermeden bir client oluşturdum: standardFlowEnabled açık, implicit ve direct access grants kapalı geldi. Ama yönetim konsolu, import edilen eski realm'ler ya da başka bir ürün farklı varsayılanlarla gelebilir; client'ı oluşturduktan sonra ayarlarını bir kez gözle kontrol et. Discovery dokümanını (.well-known/openid-configuration) düzenli kontrol et: grant_types_supported sunucunun desteğini, client ayarları ise gerçekte izin verileni söyler.
Token endpoint'i yoklayan device client'ları, slow_down'a uymazsa authorization server'ı gereksiz yere meşgul eder; aralığı her zaman sunucudan al ve artır. Client credentials token'larını her istekte yeniden alma: her token isteği bir ağ gidiş dönüşü, bir client doğrulaması ve bir imza işlemi; 5 dakikalık bir token'ı her saniye istemek authorization server'a 300 kat fazla yük demek. Token'ı süresi bitmeden biraz önce yenileyen bir cache yeterli.
Client secret'larını kodda değil secret store'da tut (ayrıntısı ilerideki "Configuration ve Secret Management" serisinde); mümkünse secret yerine private_key_jwt gibi asymmetric client authentication kullan. Bir secret sızarsa authorization server'da yeniden üretmek tek adımlık iş olmalı; bunu bir kez prova et.
Sık Düşülen Tuzaklar
Birincisi, access token'ı "kullanıcı giriş yaptı" kanıtı saymak; yukarıdaki saldırı tam bu. İkincisi, scope'u kullanıcının yetkisi sanmak: orders.read scope'u "bu client sipariş okuyabilir" der, "Ada Grace'in siparişini okuyabilir" demez. Kaynak düzeyindeki kontrol hâlâ API'de; Web Güvenliği (OWASP Top 10) serisinin "Broken Access Control (1)" yazısında IDOR (Insecure Direct Object Reference) örneğiyle görmüştük. Üçüncüsü, public client'a secret gömmek ve onu confidential sanmak; Angular bundle'ındaki secret herkesin elinde.
Dördüncüsü, mobil uygulama ya da kurum içi araç için "sadece bu seferlik" password grant açmak. Beşincisi, OAuth 2.1'i yayımlanmış bir standart gibi sözleşmelere yazmak; doğru referans RFC 9700 ve kullandığın her ek RFC. Altıncısı, device grant'i "kolay giriş" diye web uygulamalarında kullanmak; kullanıcıyı kod kopyalamaya alıştırmak phishing'e kapı açar.
Küçük Bir Deneme
★☆☆ Lab'da bookstore-tv için device akışını başlat, doğrulama sayfasında onay yerine reddet. Uygulamanın hangi error değerini aldığını ve döngünün nasıl bittiğini gözle.
★★☆ Device akışında Task.Delay'i interval yerine sabit 1 saniye yap. Keycloak'un slow_down döndürüp döndürmediğini ve kaçıncı denemede döndürdüğünü ölç; kodun aralığı nasıl büyüttüğünü çıktıda göster.
★★★ bookstore-inventory-worker için client secret yerine private_key_jwt ile client authentication kur: çalışma klasöründe bir RSA (Rivest-Shamir-Adleman) anahtarı üret, public key'i Keycloak'a ver ve token isteğinde imzalı bir client_assertion gönder.
Roller, kanallar ve grant'ler yerine oturdu. Bir sonraki yazıda authorization code akışının içine giriyoruz: code_verifier ile code_challenge'ı kendimiz üretip Keycloak'la bütün HTTP trafiğini curl'le okuyacak, state ve nonce'un neyi engellediğini görecek, PAR (Pushed Authorization Requests) ile authorization request'i front channel'dan tamamen çekeceğiz.