OAuth 2.0 Token Introspection: API Gateway’de Token Doğrulama ve Yetkilendirme
API gateway’inizde her gelen request’i nasıl doğruluyorsunuz? Basit bir JWT signature kontrolü yeterli mi, yoksa token’ın hâlâ geçerli olup olmadığını anlık olarak sorgulamanız mı gerekiyor? Çoğu ekip bu soruyla karşılaştığında OAuth 2.0 Token Introspection’ı keşfediyor. RFC 7662 ile standartlaşan bu mekanizma, özellikle token revocation’ın kritik olduğu senaryolarda hayat kurtarıcı. Bu yazıda gerçek bir API gateway kurulumunda token introspection’ı nasıl implemente edeceğinizi, performans tuzaklarını nasıl aşacağınızı ve yetkilendirme katmanını nasıl inşa edeceğinizi adım adım ele alacağız.
Token Introspection Nedir ve JWT Doğrulamadan Farkı Ne?
JWT tabanlı doğrulamada gateway, token’ın imzasını public key ile verify eder. Bu işlem tamamen stateless’tır, authorization server’a gitmez, hızlıdır. Ama bir sorun var: Token’ı revoke ettiğinizde bile expiry time dolana kadar teknik olarak “geçerli” görünmeye devam eder.
Token introspection tam da burada devreye girer. Gateway her request’te authorization server’a sorar: “Bu token hâlâ aktif mi?” Server da detaylı metadata ile yanıt verir. Evet, network call ekler ama güvenlik garantisi çok daha güçtür.
Gerçek dünya senaryosu olarak şunu düşünün: Bir fintech uygulamasında kullanıcı hesabı fraud tespiti nedeniyle anında askıya alındı. JWT tabanlı sistemde kullanıcının 1 saatlik token’ı dolana kadar API’lara erişimi devam eder. Introspection endpoint varsa, token revoke edildiği andan itibaren tüm istekler reddedilir.
RFC 7662 Temel Yapısı
Introspection endpoint’i standart bir HTTP POST isteği alır:
curl -X POST https://auth.example.com/oauth2/introspect
-H "Content-Type: application/x-www-form-urlencoded"
-H "Authorization: Basic $(echo -n 'gateway-client:secret123' | base64)"
-d "token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
Başarılı bir yanıt şöyle görünür:
# Active token response
{
"active": true,
"client_id": "mobile-app",
"username": "[email protected]",
"sub": "user-uuid-1234",
"scope": "read:orders write:cart",
"exp": 1735689600,
"iat": 1735686000,
"token_type": "Bearer",
"aud": ["orders-api", "cart-api"]
}
# Revoked/expired token response
{
"active": false
}
En kritik alan active field’ıdır. false geliyorsa başka hiçbir field’a bakmanıza gerek yok, token geçersiz.
NGINX ile Token Introspection Gateway Kurulumu
Pratik bir senaryo yapalım. NGINX tabanlı API gateway’inizde her request’i Keycloak introspection endpoint’ine doğrulattıracaksınız.
Önce NGINX yapılandırması:
# /etc/nginx/conf.d/api-gateway.conf
upstream backend_api {
server 10.0.1.10:8080;
server 10.0.1.11:8080;
keepalive 32;
}
upstream keycloak {
server auth.internal:8080;
keepalive 16;
}
# Introspection subrequest handler
server {
listen 8081;
location /oauth2/introspect {
internal;
proxy_pass http://keycloak/realms/production/protocol/openid-connect/token/introspect;
proxy_method POST;
proxy_set_header Content-Type "application/x-www-form-urlencoded";
proxy_set_header Authorization "Basic Z2F0ZXdheS1jbGllbnQ6c2VjcmV0MTIz";
proxy_pass_request_headers off;
proxy_set_body "token=$http_authorization&token_type_hint=access_token";
}
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/nginx/ssl/api.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/api.example.com.key;
location /api/ {
auth_request /auth;
auth_request_set $auth_status $upstream_status;
auth_request_set $auth_sub $upstream_http_x_user_sub;
auth_request_set $auth_scope $upstream_http_x_user_scope;
proxy_set_header X-User-Sub $auth_sub;
proxy_set_header X-User-Scope $auth_scope;
proxy_pass http://backend_api;
}
location = /auth {
internal;
proxy_pass http://localhost:8082/validate;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header Authorization $http_authorization;
}
}
Burada dikkat edilmesi gereken nokta, auth_request modülünün subrequest yanıtını nasıl kullandığıdır. 200 dönerse request geçer, 401/403 dönerse reddedilir.
Python ile Introspection Validation Service
NGINX’in auth_request yönlendirdiği validation service’i yazalım:
# validation_service.py
from fastapi import FastAPI, Request, HTTPException, Header
from fastapi.responses import JSONResponse
import httpx
import redis
import json
import hashlib
import time
import logging
app = FastAPI()
logger = logging.getLogger(__name__)
# Redis cache - introspection sonuçlarını cache'le
redis_client = redis.Redis(host='redis.internal', port=6379, db=1, decode_responses=True)
INTROSPECTION_ENDPOINT = "https://auth.internal:8080/realms/production/protocol/openid-connect/token/introspect"
GATEWAY_CLIENT_ID = "gateway-client"
GATEWAY_CLIENT_SECRET = "secret123"
CACHE_TTL = 30 # saniye - dikkatli seçin!
async def introspect_token(token: str) -> dict:
"""Authorization server'a token'ı sorgula"""
# Cache key olarak token hash kullan (güvenlik için)
cache_key = f"introspect:{hashlib.sha256(token.encode()).hexdigest()}"
# Cache'e bak
cached = redis_client.get(cache_key)
if cached:
logger.debug("Cache hit for token")
return json.loads(cached)
# Authorization server'a sor
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
INTROSPECTION_ENDPOINT,
data={"token": token, "token_type_hint": "access_token"},
auth=(GATEWAY_CLIENT_ID, GATEWAY_CLIENT_SECRET),
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code != 200:
logger.error(f"Introspection endpoint error: {response.status_code}")
raise HTTPException(status_code=503, detail="Auth service unavailable")
result = response.json()
# Sadece active token'ları cache'le ve TTL'i exp ile sınırla
if result.get("active") and "exp" in result:
remaining = result["exp"] - int(time.time())
ttl = min(CACHE_TTL, max(0, remaining))
if ttl > 0:
redis_client.setex(cache_key, ttl, json.dumps(result))
return result
@app.get("/validate")
async def validate_token(
request: Request,
authorization: str = Header(None),
x_original_uri: str = Header(None)
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
token = authorization.split(" ", 1)[1]
try:
token_info = await introspect_token(token)
except HTTPException:
raise
except Exception as e:
logger.exception("Unexpected error during introspection")
raise HTTPException(status_code=500)
if not token_info.get("active"):
raise HTTPException(status_code=401, detail="Token is not active")
# Scope kontrolü - endpoint'e göre yetki kontrolü
required_scope = get_required_scope(x_original_uri)
if required_scope:
token_scopes = set(token_info.get("scope", "").split())
if required_scope not in token_scopes:
raise HTTPException(status_code=403, detail="Insufficient scope")
# Downstream service'lere user bilgilerini header olarak geç
return JSONResponse(
status_code=200,
headers={
"X-User-Sub": token_info.get("sub", ""),
"X-User-Scope": token_info.get("scope", ""),
"X-Client-Id": token_info.get("client_id", "")
}
)
def get_required_scope(uri: str) -> str:
"""URI'ya göre gerekli scope'u döndür"""
scope_map = {
"/api/orders": "read:orders",
"/api/orders/create": "write:orders",
"/api/admin": "admin:full"
}
for path, scope in scope_map.items():
if uri and uri.startswith(path):
return scope
return None
Cache Stratejisi: Performans ve Güvenlik Dengesi
En sık sorulan soru şu: “Her request’te introspection endpoint’e gidersek sistem yavaşlamaz mı?” Evet, yavaşlar. Ama çözüm var.
Cache TTL’ini doğru seçmek kritik. Çok kısa TTL, authorization server’a çok fazla istek gönderir. Çok uzun TTL, revoked token’ların sistem içinde yaşamasına izin verir.
Önerilen strateji:
- Normal API’lar için 30-60 saniye
- Finans işlemleri için 0-5 saniye (ya da hiç cache’leme)
- Admin işlemleri için hiç cache’leme
Token revocation sonrası cache’i aktif olarak temizlemek için webhook kullanabilirsiniz:
# Keycloak event listener'dan gelen revocation webhook'u işle
# /revoke endpoint'i cache'i anında temizler
curl -X POST https://gateway.internal:8082/admin/revoke
-H "Authorization: Bearer $ADMIN_TOKEN"
-H "Content-Type: application/json"
-d '{
"token_id": "specific-token-jti",
"user_id": "user-uuid-1234",
"reason": "account_suspended"
}'
# Revocation webhook handler
@app.post("/admin/revoke")
async def handle_revocation(request: Request):
"""Keycloak'dan gelen revocation event'ini işle"""
data = await request.json()
user_id = data.get("user_id")
token_id = data.get("token_id")
# Kullanıcının tüm cached token'larını temizle
# Pattern bazlı silme - SCAN kullan, KEYS kullanma!
cursor = 0
deleted_count = 0
while True:
cursor, keys = redis_client.scan(
cursor,
match=f"introspect:*",
count=100
)
for key in keys:
cached_data = redis_client.get(key)
if cached_data:
token_data = json.loads(cached_data)
if token_data.get("sub") == user_id:
redis_client.delete(key)
deleted_count += 1
if cursor == 0:
break
logger.info(f"Revoked {deleted_count} cached tokens for user {user_id}")
return {"cleared": deleted_count}
Scope Bazlı Yetkilendirme ve RBAC Entegrasyonu
Token’ın aktif olduğunu doğrulamak yeterli değil. Asıl güç, scope ve claim bazlı ince taneli yetkilendirmede. Şöyle bir senaryo düşünelim: Aynı token hem okuma hem yazma scope’una sahip olabilir, ama siz sadece belirli endpoint’ler için yazma yetkisi vermek istiyorsunuz.
# scope_validator.py - Daha gelişmiş scope kontrolü
class ScopeValidator:
# Endpoint -> Required scopes (OR mantığı: herhangi biri yeterli)
ENDPOINT_SCOPES = {
("GET", "/api/orders"): ["read:orders", "admin:full"],
("POST", "/api/orders"): ["write:orders", "admin:full"],
("DELETE", "/api/orders"): ["admin:orders", "admin:full"],
("GET", "/api/users"): ["read:users", "admin:full"],
("PUT", "/api/users"): ["write:users", "admin:full"],
}
@classmethod
def check_scope(cls, method: str, path: str, token_scopes: list) -> bool:
"""Token'ın endpoint için gerekli scope'a sahip olup olmadığını kontrol et"""
# Normalize path - query string'i çıkar
clean_path = path.split("?")[0].rstrip("/")
# Exact match dene
required = cls.ENDPOINT_SCOPES.get((method, clean_path))
# Prefix match dene
if not required:
for (ep_method, ep_path), scopes in cls.ENDPOINT_SCOPES.items():
if method == ep_method and clean_path.startswith(ep_path):
required = scopes
break
# Tanımlı değilse varsayılan olarak izin ver (güvenlik politikanıza göre değiştirin)
if not required:
return True
# Token scope'larından herhangi biri required listesinde varsa izin ver
token_scope_set = set(token_scopes)
return bool(token_scope_set.intersection(set(required)))
@classmethod
def extract_roles(cls, token_info: dict) -> list:
"""Keycloak realm_access claims'inden rolleri çıkar"""
roles = []
# Realm roles
realm_access = token_info.get("realm_access", {})
roles.extend(realm_access.get("roles", []))
# Resource roles
resource_access = token_info.get("resource_access", {})
for resource, access in resource_access.items():
for role in access.get("roles", []):
roles.append(f"{resource}:{role}")
return roles
Kong Gateway ile Introspection Plugin
Kong kullananlar için özel bir plugin yazalım:
-- kong/plugins/token-introspection/handler.lua
local http = require "resty.http"
local cjson = require "cjson"
local sha256 = require "resty.sha256"
local str = require "resty.string"
local TokenIntrospectionHandler = {
PRIORITY = 1000,
VERSION = "1.0.0"
}
function TokenIntrospectionHandler:access(conf)
local authorization = kong.request.get_header("Authorization")
if not authorization then
return kong.response.exit(401, { message = "Missing Authorization header" })
end
local token = authorization:match("Bearer%s+(.+)")
if not token then
return kong.response.exit(401, { message = "Invalid Authorization format" })
end
-- Cache kontrolü
local cache_key = "introspect_" .. sha256_hex(token)
local cached, err = kong.cache:get(cache_key, { ttl = conf.cache_ttl },
function()
return introspect(conf, token)
end
)
if err then
kong.log.err("Cache error: ", err)
return kong.response.exit(503, { message = "Service unavailable" })
end
if not cached or not cached.active then
kong.cache:invalidate(cache_key)
return kong.response.exit(401, { message = "Token inactive or revoked" })
end
-- Upstream'e header'ları ekle
kong.service.request.set_header("X-User-Sub", cached.sub or "")
kong.service.request.set_header("X-User-Scope", cached.scope or "")
kong.service.request.set_header("X-Client-Id", cached.client_id or "")
end
function introspect(conf, token)
local httpc = http.new()
httpc:set_timeout(conf.timeout or 5000)
local res, err = httpc:request_uri(conf.introspection_endpoint, {
method = "POST",
body = "token=" .. ngx.escape_uri(token) .. "&token_type_hint=access_token",
headers = {
["Content-Type"] = "application/x-www-form-urlencoded",
["Authorization"] = "Basic " .. ngx.encode_base64(
conf.client_id .. ":" .. conf.client_secret
)
},
ssl_verify = conf.ssl_verify
})
if err or res.status ~= 200 then
return nil, "Introspection failed: " .. (err or res.status)
end
return cjson.decode(res.body)
end
return TokenIntrospectionHandler
Monitoring ve Alerting
Introspection endpoint kritik bir bağımlılık. Onun sağlığını izlemek şart:
#!/bin/bash
# /usr/local/bin/monitor-introspection.sh
# Cron: */1 * * * * /usr/local/bin/monitor-introspection.sh
ENDPOINT="https://auth.internal:8080/realms/production/protocol/openid-connect/token/introspect"
GATEWAY_CRED=$(echo -n "gateway-monitor:secret" | base64)
THRESHOLD_MS=500
LOG_FILE="/var/log/introspection-monitor.log"
# Test token ile response time ölç
START=$(date +%s%3N)
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}"
--max-time 5
-X POST "$ENDPOINT"
-H "Authorization: Basic $GATEWAY_CRED"
-H "Content-Type: application/x-www-form-urlencoded"
-d "token=test_invalid_token")
END=$(date +%s%3N)
DURATION=$((END - START))
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
if [ "$RESPONSE" != "200" ]; then
echo "$TIMESTAMP ERROR: Introspection endpoint returned $RESPONSE" >> $LOG_FILE
# PagerDuty veya Slack webhook tetikle
curl -s -X POST "$SLACK_WEBHOOK_URL"
-H 'Content-type: application/json'
-d "{"text":"ALERT: Introspection endpoint down! HTTP $RESPONSE"}"
elif [ "$DURATION" -gt "$THRESHOLD_MS" ]; then
echo "$TIMESTAMP WARN: Introspection latency ${DURATION}ms (threshold: ${THRESHOLD_MS}ms)" >> $LOG_FILE
else
echo "$TIMESTAMP OK: Introspection endpoint healthy (${DURATION}ms)" >> $LOG_FILE
fi
# Prometheus metrics push
cat <<EOF | curl -s --data-binary @- http://pushgateway.internal:9091/metrics/job/introspection_monitor
# HELP introspection_latency_ms Introspection endpoint latency in milliseconds
# TYPE introspection_latency_ms gauge
introspection_latency_ms $DURATION
# HELP introspection_up Whether introspection endpoint is up
# TYPE introspection_up gauge
introspection_up $([ "$RESPONSE" = "200" ] && echo 1 || echo 0)
EOF
Circuit Breaker Pattern
Authorization server çöktüğünde ne yaparsınız? Tüm trafiği kesmek mi, yoksa son bilinen duruma göre geçici kararlar mı vermek? Bu noktada circuit breaker pattern kritik:
Açık durum (Open): Authorization server erişilemiyor, son X dakika içindeki başarısız rate eşiği aşıldı. Bu durumda ya tüm trafiği reddet ya da sadece cache’deki geçerli token’lara izin ver.
Yarı açık durum (Half-Open): Belirli aralıklarla auth server’ı test et. Yanıt gelince kapat.
Kapalı durum (Closed): Normal operasyon, her şey çalışıyor.
Circuit breaker implementasyonunda dikkat edilmesi gerekenler:
- Fail-open vs fail-closed: Güvenlik kritik sistemlerde fail-closed (auth server yoksa trafik geçmesin) tercih edilmeli
- Cache fallback: Son başarılı introspection sonucunu kısa süre kullanabilirsiniz
- Timeout ayarı: Introspection timeout değeri çok önemli, 2-5 saniye makul
Yaygın Hatalar ve Çözümleri
Token’ı direkt log’a yazmak: Asla token’ın kendisini log’a atmayın. Hash’ini kullanın. Üretim sistemlerinde bu kaçınılması gereken en kritik hata.
Cache TTL’ini çok uzun tutmak: 5 dakikalık TTL ile revoke edilmiş bir admin token’ı sisteme girmeye devam eder. Risk toleransınıza göre belirleyin.
Introspection endpoint’e authentication koymamak: Gateway client’ınızın credential’ları olmadan herkes introspection yapabilir. Endpoint mutlaka güvende olmalı.
Client credentials’ı hardcode etmek: Örnek kodda gösterdim ama production’da environment variable veya Vault kullanın.
Audience kontrolünü atlamak: Token introspection yanıtındaki aud field’ını kontrol edin. Token başka bir servis için üretilmiş olabilir.
Sonuç
Token introspection, JWT signature validation’ın yetersiz kaldığı durumlarda güçlü bir alternatif sunuyor. Özellikle anında token revocation gerektiren finans, sağlık veya kurumsal uygulamalarda bu mekanizma olmadan gerçek anlamda güvenli bir API gateway inşa etmek zor.
Pratik özet olarak şunları söyleyebilirim: Redis ile cache katmanı koyun, cache TTL’ini 30-60 saniye civarında tutun, token revocation event’lerini webhook ile cache’e yansıtın, introspection endpoint latency’sini Prometheus ile izleyin ve circuit breaker olmadan production’a çıkmayın.
En önemli karar fail-open vs fail-closed meselesi. Güvenlik önceliğinizse authorization server erişilemediğinde tüm trafiği reddedin. Availability önceliğinizse kısa süreli cache fallback kullanın ama bu kararı bilinçli olarak verin ve ekibinizle belgeleyin.
Bu konuda sorularınız varsa veya kendi ortamınızda karşılaştığınız özel durumları paylaşmak isterseniz yorum bölümünde buluşalım.
