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.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir