กลับไปที่บทความ
Authentication Security Microservices Architecture

OAuth 2.0 Client Credentials Grant การยืนยันตัวตนระหว่างเซิร์ฟเวอร์ใน Microservices

พลากร วรมงคล
24 เมษายน 2568 13 นาที

“คำแนะนำการใช้งาน OAuth 2.0 Client Credentials Grant อย่างครบถ้วนสำหรับการยืนยันตัวตนระหว่างเครื่อง — ครอบคลุม client credentials flow, token caching และการรีเฟรช, การตรวจสอบ scope และ audience, และการใช้งานกับ Node.js และ identity provider หลายเจ้า”

Deep Dive: OAuth 2.0 Client Credentials Grant

What is the OAuth 2.0 Client Credentials Grant?

OAuth 2.0 is a delegation framework — it lets a user (resource owner) grant a third-party application access to their resources without sharing credentials. But one OAuth 2.0 flow has nothing to do with users at all: the Client Credentials Grant (RFC 6749, Section 4.4).

In this flow, a service authenticates as itself — not on behalf of any user — using its own client_id and client_secret. The authorization server validates these credentials and issues a short-lived access token. The service then presents this token to downstream APIs, which verify the token’s signature and check its scopes before granting access.

This is the M2M (machine-to-machine) profile of OAuth 2.0, and it’s the industry standard for service-to-service authentication when you already have an identity provider in your stack.

Core Principles

  • Client identity: Services are “clients” in OAuth terminology — each service gets a unique client_id and client_secret.
  • Short-lived tokens: Access tokens expire (typically 1-24 hours), limiting the blast radius of token leaks.
  • Scope-based authorization: Each token carries scopes that define what the bearer can do (payments:write, inventory:read).
  • Audience validation: The aud claim restricts which services can accept a given token.
  • Bearer tokens: Tokens are self-contained (JWT) or opaque; possession grants access — no additional secret needed to use them.
  • Stateless verification: JWT tokens can be verified by any service with the authorization server’s public key.

Authentication Flow

sequenceDiagram
    participant OS as Order Service
    participant AS as Auth Server (Keycloak/Auth0)
    participant PS as Payment Service

    Note over OS: Needs to call Payment Service
    OS->>AS: POST /oauth/token
    Note right of OS: client_id + client_secret + grant_type=client_credentials + scope=payments:write
    AS->>AS: Validate client credentials
    AS->>AS: Issue JWT access token (1h TTL)
    AS-->>OS: {access_token, token_type: "Bearer", expires_in: 3600, scope: "payments:write"}

    Note over OS: Cache token for up to expires_in seconds

    OS->>PS: POST /api/payments/charge
    Note right of OS: Authorization: Bearer eyJhbG...
    PS->>PS: Verify JWT signature (offline using JWKS)
    PS->>PS: Check scope includes "payments:write"
    PS->>PS: Check aud matches "payment-service"
    PS-->>OS: 200 OK — Charge processed

    Note over OS: Token approaching expiry — refresh proactively
    OS->>AS: POST /oauth/token (new request)
    AS-->>OS: New access token

Part 1: Setting Up an Authorization Server

Keycloak (Self-Hosted)

Keycloak is the most popular open-source identity provider for on-premises deployments:

# Run Keycloak locally
docker run -p 8080:8080 \
  -e KEYCLOAK_ADMIN=admin \
  -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:24.0.0 start-dev

Configure via Keycloak Admin UI or API:

#!/bin/bash
# keycloak-setup.sh — Configure realm and service clients via Keycloak Admin API

KEYCLOAK_URL="http://localhost:8080"
REALM="mycompany"
ADMIN_TOKEN=$(curl -s -X POST \
  "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=admin-cli&client_secret=admin&grant_type=password&username=admin&password=admin" \
  | jq -r '.access_token')

# Create realm
curl -s -X POST "${KEYCLOAK_URL}/admin/realms" \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"realm":"mycompany","enabled":true,"accessTokenLifespan":3600}'

# Create "order-service" client
curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "clientId": "order-service",
    "secret": "order-service-secret-change-in-prod",
    "serviceAccountsEnabled": true,
    "publicClient": false,
    "standardFlowEnabled": false,
    "directAccessGrantsEnabled": false,
    "attributes": {"access.token.lifespan": "3600"}
  }'

echo "Setup complete"

Auth0 (Cloud-Managed)

Auth0 simplifies M2M setup:

# Create an M2M Application via Auth0 Management API
curl -X POST https://${AUTH0_DOMAIN}/api/v2/clients \
  -H "Authorization: Bearer ${MANAGEMENT_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order-service",
    "app_type": "non_interactive",
    "grant_types": ["client_credentials"]
  }'

# Authorize the application for an API
curl -X POST https://${AUTH0_DOMAIN}/api/v2/client-grants \
  -H "Authorization: Bearer ${MANAGEMENT_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "YOUR_CLIENT_ID",
    "audience": "https://payment-service.internal",
    "scope": ["payments:write", "payments:read"]
  }'

Part 2: Token Fetching with Caching

The most important implementation detail for client credentials is token caching. Fetching a new token on every request adds 50-200ms of latency and hammers your authorization server. Tokens should be cached until near expiry, then refreshed proactively:

import axios from 'axios';

// ============================================================================
// Token Cache Entry
// ============================================================================

interface TokenEntry {
  accessToken: string;
  expiresAt: number; // Unix timestamp in milliseconds
  scope: string;
}

// ============================================================================
// OAuth2 Client Credentials Manager
// ============================================================================

interface OAuth2Config {
  tokenUrl: string;           // e.g. https://keycloak/realms/mycompany/protocol/openid-connect/token
  clientId: string;
  clientSecret: string;
  scope: string;
  audience?: string;          // Required for Auth0
  refreshThresholdSeconds?: number; // Refresh this many seconds before expiry (default: 60)
}

class OAuth2ClientCredentials {
  private config: OAuth2Config;
  private tokenCache = new Map<string, TokenEntry>();
  private inflightRequests = new Map<string, Promise<TokenEntry>>();
  private refreshThreshold: number;

  constructor(config: OAuth2Config) {
    this.config = config;
    this.refreshThreshold = (config.refreshThresholdSeconds ?? 60) * 1000;
  }

  // Cache key includes scope + audience for multi-scope clients
  private cacheKey(): string {
    return `${this.config.clientId}:${this.config.scope}:${this.config.audience ?? ''}`;
  }

  // Check if current cached token is still valid
  private isTokenValid(entry: TokenEntry): boolean {
    return Date.now() < entry.expiresAt - this.refreshThreshold;
  }

  // Fetch a fresh token from the authorization server
  private async fetchToken(): Promise<TokenEntry> {
    const params = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      scope: this.config.scope,
    });

    if (this.config.audience) {
      params.set('audience', this.config.audience);
    }

    const response = await axios.post<{
      access_token: string;
      expires_in: number;
      token_type: string;
      scope: string;
    }>(this.config.tokenUrl, params.toString(), {
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      timeout: 5000,
    });

    const { access_token, expires_in } = response.data;

    return {
      accessToken: access_token,
      // expires_in is in seconds; subtract threshold for early refresh
      expiresAt: Date.now() + expires_in * 1000,
      scope: response.data.scope,
    };
  }

  // Get a valid token, using the cache or fetching a new one
  // Uses a single-flight pattern to prevent concurrent fetch storms
  async getAccessToken(): Promise<string> {
    const key = this.cacheKey();

    // Return cached token if valid
    const cached = this.tokenCache.get(key);
    if (cached && this.isTokenValid(cached)) {
      return cached.accessToken;
    }

    // If there's already a fetch in flight, wait for it
    const inflight = this.inflightRequests.get(key);
    if (inflight) {
      const entry = await inflight;
      return entry.accessToken;
    }

    // Start a new fetch, record it as inflight
    const fetchPromise = this.fetchToken().then((entry) => {
      this.tokenCache.set(key, entry);
      this.inflightRequests.delete(key);
      return entry;
    }).catch((error) => {
      this.inflightRequests.delete(key);
      throw error;
    });

    this.inflightRequests.set(key, fetchPromise);
    const entry = await fetchPromise;
    return entry.accessToken;
  }

  // Explicitly invalidate the cache (e.g., on 401 response)
  invalidate(): void {
    this.tokenCache.delete(this.cacheKey());
  }
}

// ============================================================================
// Multi-Service Token Manager
// ============================================================================

// Manage tokens for calls to different services (each may have different scopes)
class ServiceTokenManager {
  private clients = new Map<string, OAuth2ClientCredentials>();
  private baseConfig: Omit<OAuth2Config, 'scope' | 'audience'>;

  constructor(baseConfig: Omit<OAuth2Config, 'scope' | 'audience'>) {
    this.baseConfig = baseConfig;
  }

  getClient(scope: string, audience?: string): OAuth2ClientCredentials {
    const key = `${scope}:${audience ?? ''}`;

    if (!this.clients.has(key)) {
      this.clients.set(key, new OAuth2ClientCredentials({
        ...this.baseConfig,
        scope,
        audience,
      }));
    }

    return this.clients.get(key)!;
  }

  async getToken(scope: string, audience?: string): Promise<string> {
    return this.getClient(scope, audience).getAccessToken();
  }
}
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.*;

// ============================================================================
// Token Cache Entry
// ============================================================================

public record TokenEntry(String accessToken, long expiresAtMs, String scope) {}

// ============================================================================
// OAuth2 Client Credentials Manager
// ============================================================================

public record OAuth2Config(
    String tokenUrl,
    String clientId,
    String clientSecret,
    String scope,
    String audience,          // Required for Auth0 (nullable)
    int refreshThresholdSeconds // default: 60
) {}

@Service
public class OAuth2ClientCredentials {
    private final OAuth2Config config;
    private final RestClient restClient;
    private final long refreshThresholdMs;

    private final ConcurrentHashMap<String, TokenEntry> tokenCache = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, CompletableFuture<TokenEntry>> inflightRequests =
        new ConcurrentHashMap<>();

    public OAuth2ClientCredentials(OAuth2Config config) {
        this.config = config;
        this.refreshThresholdMs = (long) config.refreshThresholdSeconds() * 1000;
        this.restClient = RestClient.create();
    }

    // Cache key includes scope + audience for multi-scope clients
    private String cacheKey() {
        return config.clientId() + ":" + config.scope() + ":"
            + Optional.ofNullable(config.audience()).orElse("");
    }

    private boolean isTokenValid(TokenEntry entry) {
        return System.currentTimeMillis() < entry.expiresAtMs() - refreshThresholdMs;
    }

    private TokenEntry fetchToken() {
        var params = new LinkedMultiValueMap<String, String>();
        params.add("grant_type", "client_credentials");
        params.add("client_id", config.clientId());
        params.add("client_secret", config.clientSecret());
        params.add("scope", config.scope());
        if (config.audience() != null) params.add("audience", config.audience());

        var response = restClient.post()
            .uri(config.tokenUrl())
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(params)
            .retrieve()
            .body(Map.class);

        String accessToken = (String) response.get("access_token");
        int expiresIn = (Integer) response.get("expires_in");
        String scope = (String) response.getOrDefault("scope", config.scope());

        return new TokenEntry(
            accessToken,
            System.currentTimeMillis() + expiresIn * 1000L,
            scope
        );
    }

    // Get a valid token — single-flight pattern prevents concurrent fetch storms
    public synchronized String getAccessToken() {
        String key = cacheKey();
        TokenEntry cached = tokenCache.get(key);
        if (cached != null && isTokenValid(cached)) return cached.accessToken();

        // Check inflight
        CompletableFuture<TokenEntry> inflight = inflightRequests.get(key);
        if (inflight != null) {
            try { return inflight.get().accessToken(); }
            catch (Exception e) { throw new RuntimeException(e); }
        }

        CompletableFuture<TokenEntry> future = CompletableFuture.supplyAsync(() -> {
            TokenEntry entry = fetchToken();
            tokenCache.put(key, entry);
            inflightRequests.remove(key);
            return entry;
        });

        inflightRequests.put(key, future);
        try {
            return future.get().accessToken();
        } catch (Exception e) {
            inflightRequests.remove(key);
            throw new RuntimeException("Failed to fetch access token", e);
        }
    }

    public void invalidate() {
        tokenCache.remove(cacheKey());
    }
}

// ============================================================================
// Multi-Service Token Manager
// ============================================================================

@Service
public class ServiceTokenManager {
    private final ConcurrentHashMap<String, OAuth2ClientCredentials> clients =
        new ConcurrentHashMap<>();
    private final OAuth2Config baseConfig;

    public ServiceTokenManager(OAuth2Config baseConfig) {
        this.baseConfig = baseConfig;
    }

    public OAuth2ClientCredentials getClient(String scope, String audience) {
        String key = scope + ":" + Optional.ofNullable(audience).orElse("");
        return clients.computeIfAbsent(key, k -> new OAuth2ClientCredentials(
            new OAuth2Config(baseConfig.tokenUrl(), baseConfig.clientId(),
                baseConfig.clientSecret(), scope, audience,
                baseConfig.refreshThresholdSeconds())
        ));
    }

    public String getToken(String scope, String audience) {
        return getClient(scope, audience).getAccessToken();
    }
}
import asyncio
import time
import aiohttp
from dataclasses import dataclass, field
from typing import Optional

# ============================================================================
# Token Cache Entry
# ============================================================================

@dataclass
class TokenEntry:
    access_token: str
    expires_at_ms: int  # Unix timestamp in milliseconds
    scope: str

# ============================================================================
# OAuth2 Client Credentials Manager
# ============================================================================

@dataclass
class OAuth2Config:
    token_url: str
    client_id: str
    client_secret: str
    scope: str
    audience: Optional[str] = None
    refresh_threshold_seconds: int = 60  # Refresh before expiry

class OAuth2ClientCredentials:
    def __init__(self, config: OAuth2Config):
        self.config = config
        self._refresh_threshold_ms = config.refresh_threshold_seconds * 1000
        self._token_cache: dict[str, TokenEntry] = {}
        self._inflight: dict[str, asyncio.Future] = {}
        self._lock = asyncio.Lock()

    def _cache_key(self) -> str:
        return f"{self.config.client_id}:{self.config.scope}:{self.config.audience or ''}"

    def _is_token_valid(self, entry: TokenEntry) -> bool:
        return int(time.time() * 1000) < entry.expires_at_ms - self._refresh_threshold_ms

    async def _fetch_token(self) -> TokenEntry:
        params = {
            "grant_type": "client_credentials",
            "client_id": self.config.client_id,
            "client_secret": self.config.client_secret,
            "scope": self.config.scope,
        }
        if self.config.audience:
            params["audience"] = self.config.audience

        async with aiohttp.ClientSession() as session:
            async with session.post(
                self.config.token_url,
                data=params,
                headers={"Content-Type": "application/x-www-form-urlencoded"},
                timeout=aiohttp.ClientTimeout(total=5),
            ) as resp:
                resp.raise_for_status()
                data = await resp.json()

        return TokenEntry(
            access_token=data["access_token"],
            # expires_in is in seconds; convert to ms
            expires_at_ms=int(time.time() * 1000) + data["expires_in"] * 1000,
            scope=data.get("scope", self.config.scope),
        )

    # Get a valid token — single-flight pattern prevents concurrent fetch storms
    async def get_access_token(self) -> str:
        key = self._cache_key()

        # Return cached token if valid
        cached = self._token_cache.get(key)
        if cached and self._is_token_valid(cached):
            return cached.access_token

        async with self._lock:
            # Double-check after acquiring lock
            cached = self._token_cache.get(key)
            if cached and self._is_token_valid(cached):
                return cached.access_token

            # If inflight, wait for it
            if key in self._inflight:
                entry = await self._inflight[key]
                return entry.access_token

            # Start a new fetch
            future: asyncio.Future = asyncio.get_event_loop().create_future()
            self._inflight[key] = future
            try:
                entry = await self._fetch_token()
                self._token_cache[key] = entry
                future.set_result(entry)
                return entry.access_token
            except Exception as e:
                future.set_exception(e)
                raise
            finally:
                self._inflight.pop(key, None)

    def invalidate(self) -> None:
        self._token_cache.pop(self._cache_key(), None)

# ============================================================================
# Multi-Service Token Manager
# ============================================================================

class ServiceTokenManager:
    def __init__(self, base_config: OAuth2Config):
        self._base_config = base_config
        self._clients: dict[str, OAuth2ClientCredentials] = {}

    def get_client(
        self, scope: str, audience: Optional[str] = None
    ) -> OAuth2ClientCredentials:
        key = f"{scope}:{audience or ''}"
        if key not in self._clients:
            self._clients[key] = OAuth2ClientCredentials(
                OAuth2Config(
                    token_url=self._base_config.token_url,
                    client_id=self._base_config.client_id,
                    client_secret=self._base_config.client_secret,
                    scope=scope,
                    audience=audience,
                )
            )
        return self._clients[key]

    async def get_token(self, scope: str, audience: Optional[str] = None) -> str:
        return await self.get_client(scope, audience).get_access_token()
using System.Collections.Concurrent;
using System.Net.Http.Json;

// ============================================================================
// Token Cache Entry
// ============================================================================

public record TokenEntry(string AccessToken, long ExpiresAtMs, string Scope);

// ============================================================================
// OAuth2 Client Credentials Manager
// ============================================================================

public record OAuth2Config(
    string TokenUrl,
    string ClientId,
    string ClientSecret,
    string Scope,
    string? Audience = null,
    int RefreshThresholdSeconds = 60 // Refresh before expiry
);

public class OAuth2ClientCredentials
{
    private readonly OAuth2Config _config;
    private readonly HttpClient _httpClient;
    private readonly long _refreshThresholdMs;

    private readonly ConcurrentDictionary<string, TokenEntry> _tokenCache = new();
    private readonly ConcurrentDictionary<string, Task<TokenEntry>> _inflightRequests = new();
    private readonly SemaphoreSlim _lock = new(1, 1);

    public OAuth2ClientCredentials(OAuth2Config config, HttpClient httpClient)
    {
        _config = config;
        _httpClient = httpClient;
        _refreshThresholdMs = config.RefreshThresholdSeconds * 1000L;
    }

    private string CacheKey() =>
        $"{_config.ClientId}:{_config.Scope}:{_config.Audience ?? ""}";

    private bool IsTokenValid(TokenEntry entry) =>
        DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() < entry.ExpiresAtMs - _refreshThresholdMs;

    private async Task<TokenEntry> FetchTokenAsync()
    {
        var formData = new Dictionary<string, string>
        {
            ["grant_type"] = "client_credentials",
            ["client_id"] = _config.ClientId,
            ["client_secret"] = _config.ClientSecret,
            ["scope"] = _config.Scope,
        };
        if (_config.Audience != null)
            formData["audience"] = _config.Audience;

        var response = await _httpClient.PostAsync(
            _config.TokenUrl, new FormUrlEncodedContent(formData));
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = data.GetProperty("access_token").GetString()!;
        var expiresIn = data.GetProperty("expires_in").GetInt32();
        var scope = data.TryGetProperty("scope", out var s) ? s.GetString()! : _config.Scope;

        return new TokenEntry(
            accessToken,
            DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + expiresIn * 1000L,
            scope
        );
    }

    // Get a valid token — single-flight pattern prevents concurrent fetch storms
    public async Task<string> GetAccessTokenAsync()
    {
        var key = CacheKey();

        if (_tokenCache.TryGetValue(key, out var cached) && IsTokenValid(cached))
            return cached.AccessToken;

        await _lock.WaitAsync();
        try
        {
            // Double-check after acquiring lock
            if (_tokenCache.TryGetValue(key, out cached) && IsTokenValid(cached))
                return cached.AccessToken;

            if (_inflightRequests.TryGetValue(key, out var inflight))
                return (await inflight).AccessToken;

            var fetchTask = FetchTokenAsync().ContinueWith(t =>
            {
                _inflightRequests.TryRemove(key, out _);
                if (t.IsCompletedSuccessfully)
                    _tokenCache[key] = t.Result;
                return t.Result;
            });

            _inflightRequests[key] = fetchTask;
            var entry = await fetchTask;
            return entry.AccessToken;
        }
        finally
        {
            _lock.Release();
        }
    }

    public void Invalidate() => _tokenCache.TryRemove(CacheKey(), out _);
}

// ============================================================================
// Multi-Service Token Manager
// ============================================================================

public class ServiceTokenManager
{
    private readonly ConcurrentDictionary<string, OAuth2ClientCredentials> _clients = new();
    private readonly OAuth2Config _baseConfig;
    private readonly HttpClient _httpClient;

    public ServiceTokenManager(OAuth2Config baseConfig, HttpClient httpClient)
    {
        _baseConfig = baseConfig;
        _httpClient = httpClient;
    }

    public OAuth2ClientCredentials GetClient(string scope, string? audience = null)
    {
        var key = $"{scope}:{audience ?? ""}";
        return _clients.GetOrAdd(key, _ => new OAuth2ClientCredentials(
            _baseConfig with { Scope = scope, Audience = audience },
            _httpClient
        ));
    }

    public Task<string> GetTokenAsync(string scope, string? audience = null) =>
        GetClient(scope, audience).GetAccessTokenAsync();
}

Part 3: HTTP Client with Automatic Token Injection

import axios, { AxiosInstance, AxiosError } from 'axios';

// ============================================================================
// OAuth2-authenticated HTTP Client
// ============================================================================

interface OAuth2HttpClientConfig {
  baseURL: string;
  tokenManager: ServiceTokenManager;
  scope: string;
  audience?: string;
  timeout?: number;
}

class OAuth2HttpClient {
  private client: AxiosInstance;
  private tokenManager: ServiceTokenManager;
  private scope: string;
  private audience?: string;

  constructor(config: OAuth2HttpClientConfig) {
    this.tokenManager = config.tokenManager;
    this.scope = config.scope;
    this.audience = config.audience;

    this.client = axios.create({
      baseURL: config.baseURL,
      timeout: config.timeout ?? 5000,
    });

    // Request interceptor: inject Bearer token
    this.client.interceptors.request.use(async (requestConfig) => {
      const token = await this.tokenManager.getToken(this.scope, this.audience);
      requestConfig.headers.Authorization = `Bearer ${token}`;
      return requestConfig;
    });

    // Response interceptor: handle 401 by refreshing token and retrying once
    this.client.interceptors.response.use(
      (response) => response,
      async (error: AxiosError) => {
        const originalRequest = error.config as any;

        if (error.response?.status === 401 && !originalRequest._retried) {
          originalRequest._retried = true;

          // Invalidate cached token — it may have been revoked or expired early
          this.tokenManager.getClient(this.scope, this.audience).invalidate();

          // Fetch a fresh token and retry
          const newToken = await this.tokenManager.getToken(this.scope, this.audience);
          originalRequest.headers.Authorization = `Bearer ${newToken}`;

          return this.client.request(originalRequest);
        }

        return Promise.reject(error);
      }
    );
  }

  async post<T>(path: string, data: unknown): Promise<T> {
    const response = await this.client.post<T>(path, data);
    return response.data;
  }

  async get<T>(path: string, params?: Record<string, string>): Promise<T> {
    const response = await this.client.get<T>(path, { params });
    return response.data;
  }
}

// ============================================================================
// Wire Everything Together (Order Service)
// ============================================================================

const tokenManager = new ServiceTokenManager({
  tokenUrl: process.env.TOKEN_URL!,   // e.g. https://keycloak/realms/mycompany/...
  clientId: process.env.CLIENT_ID!,   // order-service
  clientSecret: process.env.CLIENT_SECRET!,
});

const paymentClient = new OAuth2HttpClient({
  baseURL: 'https://payment-service.internal',
  tokenManager,
  scope: 'payments:write',
  audience: 'https://payment-service.internal',
});

const inventoryClient = new OAuth2HttpClient({
  baseURL: 'https://inventory-service.internal',
  tokenManager,
  scope: 'inventory:read',
  audience: 'https://inventory-service.internal',
});

// Usage
async function processOrder(orderId: string) {
  const stock = await inventoryClient.get<{ available: number }>(
    `/api/inventory/check/${orderId}`
  );

  if (stock.available > 0) {
    const charge = await paymentClient.post('/api/payments/charge', {
      orderId,
      amount: 9999,
    });

    return { success: true, chargeId: charge };
  }
}
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.HttpClientErrorException;

// ============================================================================
// OAuth2-authenticated HTTP Client
// ============================================================================

public record OAuth2HttpClientConfig(
    String baseURL,
    ServiceTokenManager tokenManager,
    String scope,
    String audience,
    int timeoutMs
) {}

@Component
public class OAuth2HttpClient {
    private final RestClient restClient;
    private final ServiceTokenManager tokenManager;
    private final String scope;
    private final String audience;

    public OAuth2HttpClient(OAuth2HttpClientConfig config) {
        this.tokenManager = config.tokenManager();
        this.scope = config.scope();
        this.audience = config.audience();
        this.restClient = RestClient.builder()
            .baseUrl(config.baseURL())
            .build();
    }

    private String getBearerToken() {
        return "Bearer " + tokenManager.getToken(scope, audience);
    }

    public <T> T post(String path, Object body, Class<T> responseType) {
        try {
            return restClient.post()
                .uri(path)
                .header(HttpHeaders.AUTHORIZATION, getBearerToken())
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(responseType);
        } catch (HttpClientErrorException.Unauthorized e) {
            // Invalidate cached token and retry once
            tokenManager.getClient(scope, audience).invalidate();
            return restClient.post()
                .uri(path)
                .header(HttpHeaders.AUTHORIZATION, getBearerToken())
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(responseType);
        }
    }

    public <T> T get(String path, Class<T> responseType) {
        try {
            return restClient.get()
                .uri(path)
                .header(HttpHeaders.AUTHORIZATION, getBearerToken())
                .retrieve()
                .body(responseType);
        } catch (HttpClientErrorException.Unauthorized e) {
            tokenManager.getClient(scope, audience).invalidate();
            return restClient.get()
                .uri(path)
                .header(HttpHeaders.AUTHORIZATION, getBearerToken())
                .retrieve()
                .body(responseType);
        }
    }
}

// ============================================================================
// Wire Everything Together (Order Service) — Spring @Configuration
// ============================================================================

@Configuration
public class ServiceClientsConfig {

    @Bean
    public ServiceTokenManager serviceTokenManager(
            @Value("${oauth2.token-url}") String tokenUrl,
            @Value("${oauth2.client-id}") String clientId,
            @Value("${oauth2.client-secret}") String clientSecret) {
        return new ServiceTokenManager(
            new OAuth2Config(tokenUrl, clientId, clientSecret, "", null, 60));
    }

    @Bean
    public OAuth2HttpClient paymentClient(ServiceTokenManager tokenManager) {
        return new OAuth2HttpClient(new OAuth2HttpClientConfig(
            "https://payment-service.internal", tokenManager,
            "payments:write", "https://payment-service.internal", 5000));
    }

    @Bean
    public OAuth2HttpClient inventoryClient(ServiceTokenManager tokenManager) {
        return new OAuth2HttpClient(new OAuth2HttpClientConfig(
            "https://inventory-service.internal", tokenManager,
            "inventory:read", "https://inventory-service.internal", 5000));
    }
}

// Usage
@Service
public class OrderProcessor {
    private final OAuth2HttpClient paymentClient;
    private final OAuth2HttpClient inventoryClient;

    public Map<String, Object> processOrder(String orderId) {
        var stock = inventoryClient.get(
            "/api/inventory/check/" + orderId, Map.class);
        if ((Integer) stock.get("available") > 0) {
            var charge = paymentClient.post(
                "/api/payments/charge",
                Map.of("orderId", orderId, "amount", 9999),
                Map.class);
            return Map.of("success", true, "chargeId", charge);
        }
        return Map.of("success", false);
    }
}
import aiohttp
import os
from typing import Optional, TypeVar, Type
from dataclasses import dataclass

T = TypeVar("T")

# ============================================================================
# OAuth2-authenticated HTTP Client
# ============================================================================

@dataclass
class OAuth2HttpClientConfig:
    base_url: str
    token_manager: "ServiceTokenManager"
    scope: str
    audience: Optional[str] = None
    timeout: int = 5

class OAuth2HttpClient:
    def __init__(self, config: OAuth2HttpClientConfig):
        self._config = config

    async def _get_auth_headers(self) -> dict[str, str]:
        token = await self._config.token_manager.get_token(
            self._config.scope, self._config.audience
        )
        return {"Authorization": f"Bearer {token}"}

    async def post(self, path: str, data: object) -> dict:
        url = self._config.base_url.rstrip("/") + path
        headers = await self._get_auth_headers()
        timeout = aiohttp.ClientTimeout(total=self._config.timeout)

        async with aiohttp.ClientSession() as session:
            async with session.post(url, json=data, headers=headers, timeout=timeout) as resp:
                if resp.status == 401:
                    # Invalidate cached token and retry once
                    self._config.token_manager.get_client(
                        self._config.scope, self._config.audience
                    ).invalidate()
                    headers = await self._get_auth_headers()
                    async with session.post(url, json=data, headers=headers, timeout=timeout) as retry:
                        retry.raise_for_status()
                        return await retry.json()
                resp.raise_for_status()
                return await resp.json()

    async def get(self, path: str, params: Optional[dict] = None) -> dict:
        url = self._config.base_url.rstrip("/") + path
        headers = await self._get_auth_headers()
        timeout = aiohttp.ClientTimeout(total=self._config.timeout)

        async with aiohttp.ClientSession() as session:
            async with session.get(url, params=params, headers=headers, timeout=timeout) as resp:
                if resp.status == 401:
                    self._config.token_manager.get_client(
                        self._config.scope, self._config.audience
                    ).invalidate()
                    headers = await self._get_auth_headers()
                    async with session.get(url, params=params, headers=headers, timeout=timeout) as retry:
                        retry.raise_for_status()
                        return await retry.json()
                resp.raise_for_status()
                return await resp.json()

# ============================================================================
# Wire Everything Together (Order Service)
# ============================================================================

token_manager = ServiceTokenManager(OAuth2Config(
    token_url=os.environ["TOKEN_URL"],
    client_id=os.environ["CLIENT_ID"],
    client_secret=os.environ["CLIENT_SECRET"],
    scope="",
))

payment_client = OAuth2HttpClient(OAuth2HttpClientConfig(
    base_url="https://payment-service.internal",
    token_manager=token_manager,
    scope="payments:write",
    audience="https://payment-service.internal",
))

inventory_client = OAuth2HttpClient(OAuth2HttpClientConfig(
    base_url="https://inventory-service.internal",
    token_manager=token_manager,
    scope="inventory:read",
    audience="https://inventory-service.internal",
))

# Usage
async def process_order(order_id: str) -> dict:
    stock = await inventory_client.get(f"/api/inventory/check/{order_id}")
    if stock.get("available", 0) > 0:
        charge = await payment_client.post("/api/payments/charge", {
            "orderId": order_id,
            "amount": 9999,
        })
        return {"success": True, "chargeId": charge}
    return {"success": False}
using System.Net.Http.Json;
using System.Text.Json;

// ============================================================================
// OAuth2-authenticated HTTP Client
// ============================================================================

public record OAuth2HttpClientConfig(
    string BaseURL,
    ServiceTokenManager TokenManager,
    string Scope,
    string? Audience = null,
    int TimeoutMs = 5000
);

public class OAuth2HttpClient
{
    private readonly HttpClient _httpClient;
    private readonly ServiceTokenManager _tokenManager;
    private readonly string _scope;
    private readonly string? _audience;

    public OAuth2HttpClient(OAuth2HttpClientConfig config, HttpClient httpClient)
    {
        _tokenManager = config.TokenManager;
        _scope = config.Scope;
        _audience = config.Audience;
        _httpClient = httpClient;
        _httpClient.BaseAddress = new Uri(config.BaseURL);
        _httpClient.Timeout = TimeSpan.FromMilliseconds(config.TimeoutMs);
    }

    private async Task<string> GetBearerTokenAsync() =>
        "Bearer " + await _tokenManager.GetTokenAsync(_scope, _audience);

    public async Task<T?> PostAsync<T>(string path, object body)
    {
        var token = await GetBearerTokenAsync();
        var request = new HttpRequestMessage(HttpMethod.Post, path)
        {
            Content = JsonContent.Create(body),
            Headers = { { "Authorization", token } }
        };

        var response = await _httpClient.SendAsync(request);

        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
        {
            // Invalidate cached token and retry once
            _tokenManager.GetClient(_scope, _audience).Invalidate();
            token = await GetBearerTokenAsync();
            request = new HttpRequestMessage(HttpMethod.Post, path)
            {
                Content = JsonContent.Create(body),
                Headers = { { "Authorization", token } }
            };
            response = await _httpClient.SendAsync(request);
        }

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<T>();
    }

    public async Task<T?> GetAsync<T>(string path, Dictionary<string, string>? queryParams = null)
    {
        var uri = path;
        if (queryParams?.Count > 0)
            uri += "?" + string.Join("&", queryParams.Select(kv =>
                $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));

        var token = await GetBearerTokenAsync();
        var request = new HttpRequestMessage(HttpMethod.Get, uri)
        {
            Headers = { { "Authorization", token } }
        };

        var response = await _httpClient.SendAsync(request);

        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
        {
            _tokenManager.GetClient(_scope, _audience).Invalidate();
            token = await GetBearerTokenAsync();
            request = new HttpRequestMessage(HttpMethod.Get, uri)
            {
                Headers = { { "Authorization", token } }
            };
            response = await _httpClient.SendAsync(request);
        }

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<T>();
    }
}

// ============================================================================
// Wire Everything Together (Order Service) — in Program.cs / DI setup
// ============================================================================

// builder.Services.AddSingleton<ServiceTokenManager>(...);
// builder.Services.AddHttpClient<OAuth2HttpClient>();

// Usage
public class OrderProcessor
{
    private readonly OAuth2HttpClient _paymentClient;
    private readonly OAuth2HttpClient _inventoryClient;

    public async Task<object> ProcessOrderAsync(string orderId)
    {
        var stock = await _inventoryClient.GetAsync<JsonElement>(
            $"/api/inventory/check/{orderId}");

        if (stock?.GetProperty("available").GetInt32() > 0)
        {
            var charge = await _paymentClient.PostAsync<JsonElement>(
                "/api/payments/charge",
                new { orderId, amount = 9999 });
            return new { success = true, chargeId = charge };
        }
        return new { success = false };
    }
}

Part 4: Token Validation on the Receiving Service

The receiving service validates the Bearer token — either by verifying the JWT signature locally, or by introspecting it with the authorization server:

import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
import express from 'express';

// ============================================================================
// JWKS-based JWT Verification (offline — no round-trip to auth server)
// ============================================================================

// Cache the JWKS remotely — jose handles key caching and rotation automatically
const JWKS = createRemoteJWKSet(
  new URL(process.env.JWKS_URI!) // e.g. https://keycloak/realms/mycompany/protocol/openid-connect/certs
);

interface ServiceClaims extends JWTPayload {
  scope?: string;
  azp?: string;    // Authorized party (Keycloak: the client_id)
  client_id?: string; // Auth0 uses this field
}

async function verifyServiceToken(token: string): Promise<ServiceClaims> {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: process.env.TOKEN_ISSUER!,        // https://keycloak/realms/mycompany
    audience: process.env.SERVICE_AUDIENCE!,  // https://payment-service.internal
    algorithms: ['RS256', 'ES256'],
  });

  return payload as ServiceClaims;
}

// ============================================================================
// Scope Enforcement
// ============================================================================

function requireScope(...requiredScopes: string[]) {
  return async (
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
  ): Promise<void> => {
    const authHeader = req.headers.authorization;

    if (!authHeader?.startsWith('Bearer ')) {
      res.status(401).json({ error: 'Missing Bearer token' });
      return;
    }

    const token = authHeader.slice(7);

    try {
      const claims = await verifyServiceToken(token);

      // Parse scopes — both space-separated strings and arrays are common
      const tokenScopes = typeof claims.scope === 'string'
        ? claims.scope.split(' ')
        : (claims.scope as string[] | undefined) ?? [];

      const missingScopes = requiredScopes.filter(s => !tokenScopes.includes(s));
      if (missingScopes.length > 0) {
        res.status(403).json({
          error: 'Insufficient scope',
          required: requiredScopes,
          missing: missingScopes,
        });
        return;
      }

      // Attach claims for route handlers
      const clientId = claims.azp || claims.client_id || claims.sub;
      (req as any).serviceIdentity = clientId;
      (req as any).tokenClaims = claims;

      next();
    } catch (error: any) {
      if (error.code === 'ERR_JWT_EXPIRED') {
        res.status(401).json({ error: 'Token expired' });
      } else if (error.code === 'ERR_JWS_INVALID') {
        res.status(401).json({ error: 'Invalid token signature' });
      } else {
        res.status(401).json({ error: 'Token verification failed' });
      }
    }
  };
}

// ============================================================================
// Payment Service Routes
// ============================================================================

app.post(
  '/api/payments/charge',
  requireScope('payments:write'),
  async (req: express.Request, res: express.Response) => {
    const callerClientId = (req as any).serviceIdentity;

    console.info('Payment charge request', {
      from: callerClientId,
      amount: req.body.amount,
    });

    res.json({
      success: true,
      chargeId: `ch_${Date.now()}`,
      processedFor: callerClientId,
    });
  }
);

app.get(
  '/api/payments/history',
  requireScope('payments:read'),
  async (req: express.Request, res: express.Response) => {
    res.json({ payments: [] });
  }
);
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.stereotype.Component;
import java.net.URL;
import java.util.*;

// ============================================================================
// JWKS-based JWT Verification (Spring Security OAuth2 Resource Server)
// ============================================================================

// In application.yml:
// spring.security.oauth2.resourceserver.jwt.jwk-set-uri: ${JWKS_URI}
// spring.security.oauth2.resourceserver.jwt.issuer-uri: ${TOKEN_ISSUER}

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter()))
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/payments/charge").hasAuthority("SCOPE_payments:write")
                .requestMatchers("/api/payments/history").hasAuthority("SCOPE_payments:read")
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtConverter() {
        var converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            // Parse scopes from JWT claim
            String scopeClaim = jwt.getClaimAsString("scope");
            if (scopeClaim == null) return List.of();
            return Arrays.stream(scopeClaim.split(" "))
                .map(s -> (GrantedAuthority) () -> "SCOPE_" + s)
                .toList();
        });
        return converter;
    }
}

// ============================================================================
// Payment Service Controller
// ============================================================================

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    @PostMapping("/charge")
    @PreAuthorize("hasAuthority('SCOPE_payments:write')")
    public ResponseEntity<Map<String, Object>> charge(
            @RequestBody Map<String, Object> request,
            @AuthenticationPrincipal Jwt jwt) {

        String callerClientId = Optional.ofNullable(jwt.getClaimAsString("azp"))
            .orElse(jwt.getClaimAsString("client_id"));

        log.info("Payment charge request from={}, amount={}", callerClientId,
            request.get("amount"));

        return ResponseEntity.ok(Map.of(
            "success", true,
            "chargeId", "ch_" + System.currentTimeMillis(),
            "processedFor", callerClientId
        ));
    }

    @GetMapping("/history")
    @PreAuthorize("hasAuthority('SCOPE_payments:read')")
    public ResponseEntity<Map<String, Object>> history() {
        return ResponseEntity.ok(Map.of("payments", List.of()));
    }
}
import os
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError, ExpiredSignatureError
import httpx

# ============================================================================
# JWKS-based JWT Verification (offline — no round-trip to auth server)
# ============================================================================

# Fetch JWKS once at startup and cache
_jwks_cache: dict | None = None

async def get_jwks() -> dict:
    global _jwks_cache
    if _jwks_cache is None:
        async with httpx.AsyncClient() as client:
            resp = await client.get(os.environ["JWKS_URI"])
            resp.raise_for_status()
            _jwks_cache = resp.json()
    return _jwks_cache

security = HTTPBearer()

async def verify_service_token(
    credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
    token = credentials.credentials
    jwks = await get_jwks()

    try:
        payload = jwt.decode(
            token,
            jwks,
            algorithms=["RS256", "ES256"],
            issuer=os.environ["TOKEN_ISSUER"],
            audience=os.environ["SERVICE_AUDIENCE"],
            options={"verify_aud": True},
        )
        return payload
    except ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except JWTError:
        raise HTTPException(status_code=401, detail="Token verification failed")

# ============================================================================
# Scope Enforcement
# ============================================================================

def require_scope(*required_scopes: str):
    async def dependency(claims: dict = Depends(verify_service_token)) -> dict:
        scope_claim = claims.get("scope", "")
        token_scopes = scope_claim.split(" ") if isinstance(scope_claim, str) else scope_claim

        missing = [s for s in required_scopes if s not in token_scopes]
        if missing:
            raise HTTPException(
                status_code=403,
                detail={
                    "error": "Insufficient scope",
                    "required": list(required_scopes),
                    "missing": missing,
                },
            )
        return claims
    return dependency

# ============================================================================
# Payment Service Routes
# ============================================================================

app = FastAPI()

@app.post("/api/payments/charge")
async def charge(
    request: Request,
    claims: dict = Depends(require_scope("payments:write")),
):
    caller_client_id = claims.get("azp") or claims.get("client_id") or claims.get("sub")
    body = await request.json()

    return {
        "success": True,
        "chargeId": f"ch_{int(time.time() * 1000)}",
        "processedFor": caller_client_id,
    }

@app.get("/api/payments/history")
async def history(claims: dict = Depends(require_scope("payments:read"))):
    return {"payments": []}
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;

// ============================================================================
// JWKS-based JWT Verification (ASP.NET Core + Microsoft.Identity.Web)
// ============================================================================

// In Program.cs:
// builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
//     .AddJwtBearer(options =>
//     {
//         options.Authority = Environment.GetEnvironmentVariable("TOKEN_ISSUER");
//         options.Audience = Environment.GetEnvironmentVariable("SERVICE_AUDIENCE");
//         options.TokenValidationParameters = new TokenValidationParameters
//         {
//             ValidateIssuer = true,
//             ValidateAudience = true,
//             ValidateLifetime = true,
//             ValidAlgorithms = new[] { "RS256", "ES256" },
//         };
//     });

// ============================================================================
// Scope Enforcement — custom policy requirement
// ============================================================================

public class ScopeRequirement : IAuthorizationRequirement
{
    public string[] RequiredScopes { get; }
    public ScopeRequirement(params string[] scopes) => RequiredScopes = scopes;
}

public class ScopeHandler : AuthorizationHandler<ScopeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, ScopeRequirement requirement)
    {
        var scopeClaim = context.User.FindFirst("scope")?.Value ?? "";
        var tokenScopes = scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        var missing = requirement.RequiredScopes
            .Where(s => !tokenScopes.Contains(s))
            .ToList();

        if (missing.Count == 0)
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

// ============================================================================
// Payment Service Controller
// ============================================================================

[ApiController]
[Route("api/payments")]
[Authorize]
public class PaymentController : ControllerBase
{
    [HttpPost("charge")]
    [Authorize(Policy = "payments:write")]
    public IActionResult Charge([FromBody] JsonElement body)
    {
        var callerClientId = User.FindFirst("azp")?.Value
            ?? User.FindFirst("client_id")?.Value
            ?? User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;

        return Ok(new
        {
            success = true,
            chargeId = $"ch_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
            processedFor = callerClientId
        });
    }

    [HttpGet("history")]
    [Authorize(Policy = "payments:read")]
    public IActionResult History() => Ok(new { payments = Array.Empty<object>() });
}

// Register policies in Program.cs:
// builder.Services.AddAuthorization(options =>
// {
//     options.AddPolicy("payments:write", p =>
//         p.Requirements.Add(new ScopeRequirement("payments:write")));
//     options.AddPolicy("payments:read", p =>
//         p.Requirements.Add(new ScopeRequirement("payments:read")));
// });
// builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();

Part 5: Token Introspection (for Opaque Tokens)

If your authorization server issues opaque tokens (not JWTs), verification requires a round-trip:

// ============================================================================
// Token Introspection (RFC 7662)
// ============================================================================

interface IntrospectionResponse {
  active: boolean;
  scope?: string;
  client_id?: string;
  sub?: string;
  exp?: number;
  iat?: number;
  aud?: string | string[];
}

interface CachedIntrospection {
  response: IntrospectionResponse;
  cachedAt: number;
  ttl: number; // seconds to cache
}

const introspectionCache = new Map<string, CachedIntrospection>();

async function introspectToken(token: string): Promise<IntrospectionResponse> {
  // Cache introspection results (cache inactive tokens for 30s, active for 60s)
  const tokenHash = require('crypto')
    .createHash('sha256')
    .update(token)
    .digest('hex')
    .slice(0, 16);

  const cached = introspectionCache.get(tokenHash);
  if (cached && Date.now() - cached.cachedAt < cached.ttl * 1000) {
    return cached.response;
  }

  const response = await axios.post<IntrospectionResponse>(
    process.env.INTROSPECTION_ENDPOINT!,
    new URLSearchParams({ token }),
    {
      auth: {
        username: process.env.INTROSPECTION_CLIENT_ID!,
        password: process.env.INTROSPECTION_CLIENT_SECRET!,
      },
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    }
  );

  const result = response.data;

  introspectionCache.set(tokenHash, {
    response: result,
    cachedAt: Date.now(),
    ttl: result.active ? 60 : 30, // Cache active tokens longer than inactive
  });

  return result;
}
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.util.LinkedMultiValueMap;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

// ============================================================================
// Token Introspection (RFC 7662)
// ============================================================================

public record IntrospectionResponse(
    boolean active,
    String scope,
    String clientId,
    String sub,
    Long exp,
    Long iat,
    Object aud
) {}

record CachedIntrospection(IntrospectionResponse response, long cachedAtMs, int ttlSeconds) {}

@Service
public class TokenIntrospectionService {
    private final RestClient restClient;
    private final String introspectionEndpoint;
    private final String clientId;
    private final String clientSecret;
    private final ConcurrentHashMap<String, CachedIntrospection> cache = new ConcurrentHashMap<>();

    public TokenIntrospectionService(
            @Value("${oauth2.introspection-endpoint}") String endpoint,
            @Value("${oauth2.introspection-client-id}") String clientId,
            @Value("${oauth2.introspection-client-secret}") String clientSecret) {
        this.introspectionEndpoint = endpoint;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.restClient = RestClient.create();
    }

    public IntrospectionResponse introspectToken(String token) throws Exception {
        // Hash token for cache key
        MessageDigest sha = MessageDigest.getInstance("SHA-256");
        String tokenHash = HexFormat.of()
            .formatHex(sha.digest(token.getBytes(StandardCharsets.UTF_8)))
            .substring(0, 16);

        CachedIntrospection cached = cache.get(tokenHash);
        if (cached != null &&
            System.currentTimeMillis() - cached.cachedAtMs() < cached.ttlSeconds() * 1000L) {
            return cached.response();
        }

        var params = new LinkedMultiValueMap<String, String>();
        params.add("token", token);

        var response = restClient.post()
            .uri(introspectionEndpoint)
            .headers(h -> h.setBasicAuth(clientId, clientSecret))
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(params)
            .retrieve()
            .body(IntrospectionResponse.class);

        int ttl = response.active() ? 60 : 30; // Cache active tokens longer
        cache.put(tokenHash, new CachedIntrospection(
            response, System.currentTimeMillis(), ttl));

        return response;
    }
}
import hashlib
import time
import aiohttp
import os
from dataclasses import dataclass
from typing import Optional

# ============================================================================
# Token Introspection (RFC 7662)
# ============================================================================

@dataclass
class IntrospectionResponse:
    active: bool
    scope: Optional[str] = None
    client_id: Optional[str] = None
    sub: Optional[str] = None
    exp: Optional[int] = None
    iat: Optional[int] = None
    aud: Optional[str | list[str]] = None

@dataclass
class CachedIntrospection:
    response: IntrospectionResponse
    cached_at: float
    ttl: int  # seconds to cache

_introspection_cache: dict[str, CachedIntrospection] = {}

async def introspect_token(token: str) -> IntrospectionResponse:
    # Hash token for cache key
    token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]

    cached = _introspection_cache.get(token_hash)
    if cached and time.time() - cached.cached_at < cached.ttl:
        return cached.response

    async with aiohttp.ClientSession() as session:
        async with session.post(
            os.environ["INTROSPECTION_ENDPOINT"],
            data={"token": token},
            auth=aiohttp.BasicAuth(
                os.environ["INTROSPECTION_CLIENT_ID"],
                os.environ["INTROSPECTION_CLIENT_SECRET"],
            ),
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        ) as resp:
            resp.raise_for_status()
            data = await resp.json()

    result = IntrospectionResponse(**{k: v for k, v in data.items()
                                       if k in IntrospectionResponse.__dataclass_fields__})

    _introspection_cache[token_hash] = CachedIntrospection(
        response=result,
        cached_at=time.time(),
        ttl=60 if result.active else 30,  # Cache active tokens longer than inactive
    )

    return result
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;

// ============================================================================
// Token Introspection (RFC 7662)
// ============================================================================

public record IntrospectionResponse(
    bool Active,
    string? Scope,
    string? ClientId,
    string? Sub,
    long? Exp,
    long? Iat,
    object? Aud
);

record CachedIntrospection(IntrospectionResponse Response, long CachedAtMs, int TtlSeconds);

public class TokenIntrospectionService
{
    private readonly HttpClient _httpClient;
    private readonly string _endpoint;
    private readonly ConcurrentDictionary<string, CachedIntrospection> _cache = new();

    public TokenIntrospectionService(HttpClient httpClient,
        IConfiguration config)
    {
        _httpClient = httpClient;
        _endpoint = config["OAuth2:IntrospectionEndpoint"]!;

        var clientId = config["OAuth2:IntrospectionClientId"]!;
        var clientSecret = config["OAuth2:IntrospectionClientSecret"]!;
        var credentials = Convert.ToBase64String(
            Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
        _httpClient.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Basic", credentials);
    }

    public async Task<IntrospectionResponse> IntrospectTokenAsync(string token)
    {
        // Hash token for cache key
        var tokenHash = Convert.ToHexString(
            SHA256.HashData(Encoding.UTF8.GetBytes(token)))[..16].ToLower();

        if (_cache.TryGetValue(tokenHash, out var cached) &&
            DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - cached.CachedAtMs
            < cached.TtlSeconds * 1000L)
        {
            return cached.Response;
        }

        var response = await _httpClient.PostAsync(_endpoint,
            new FormUrlEncodedContent(new[] {
                new KeyValuePair<string, string>("token", token)
            }));
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadFromJsonAsync<IntrospectionResponse>();
        var ttl = data!.Active ? 60 : 30; // Cache active tokens longer than inactive

        _cache[tokenHash] = new CachedIntrospection(
            data,
            DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
            ttl
        );

        return data;
    }
}

Part 6: Working with Multiple Identity Providers

Different providers have slightly different implementations of client credentials:

// ============================================================================
// Provider-specific configurations
// ============================================================================

type ProviderConfig = {
  tokenUrl: string;
  extraParams?: Record<string, string>;
};

function getProviderConfig(provider: 'keycloak' | 'auth0' | 'azure-ad' | 'aws-cognito'): ProviderConfig {
  switch (provider) {
    case 'keycloak':
      return {
        tokenUrl: `${process.env.KEYCLOAK_URL}/realms/${process.env.REALM}/protocol/openid-connect/token`,
        // scope param is supported directly
      };

    case 'auth0':
      return {
        tokenUrl: `https://${process.env.AUTH0_DOMAIN}/oauth/token`,
        extraParams: {
          audience: process.env.AUTH0_AUDIENCE!, // Auth0 requires explicit audience
        },
      };

    case 'azure-ad':
      return {
        tokenUrl: `https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`,
        extraParams: {
          // Azure AD uses /.default scope to grant all pre-configured permissions
          scope: `${process.env.AZURE_APP_ID_URI}/.default`,
        },
      };

    case 'aws-cognito':
      return {
        tokenUrl: `https://${process.env.COGNITO_DOMAIN}/oauth2/token`,
        // Cognito uses custom scopes defined in the resource server
      };
  }
}

// ============================================================================
// Unified token fetcher (handles provider differences)
// ============================================================================

async function fetchClientCredentialsToken(
  provider: 'keycloak' | 'auth0' | 'azure-ad' | 'aws-cognito',
  clientId: string,
  clientSecret: string,
  scope: string
): Promise<{ accessToken: string; expiresIn: number }> {
  const { tokenUrl, extraParams = {} } = getProviderConfig(provider);

  const params = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: clientId,
    client_secret: clientSecret,
    scope,
    ...extraParams,
  });

  const response = await axios.post<{ access_token: string; expires_in: number }>(
    tokenUrl,
    params.toString(),
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
  );

  return {
    accessToken: response.data.access_token,
    expiresIn: response.data.expires_in,
  };
}
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestClient;
import java.util.Map;

// ============================================================================
// Provider-specific configurations
// ============================================================================

public record ProviderConfig(String tokenUrl, Map<String, String> extraParams) {}

public record TokenResult(String accessToken, int expiresIn) {}

@Service
public class MultiProviderTokenService {
    private final RestClient restClient = RestClient.create();

    public ProviderConfig getProviderConfig(String provider) {
        return switch (provider) {
            case "keycloak" -> new ProviderConfig(
                System.getenv("KEYCLOAK_URL") + "/realms/"
                    + System.getenv("REALM") + "/protocol/openid-connect/token",
                Map.of()
            );
            case "auth0" -> new ProviderConfig(
                "https://" + System.getenv("AUTH0_DOMAIN") + "/oauth/token",
                Map.of("audience", System.getenv("AUTH0_AUDIENCE"))
            );
            case "azure-ad" -> new ProviderConfig(
                "https://login.microsoftonline.com/"
                    + System.getenv("TENANT_ID") + "/oauth2/v2.0/token",
                Map.of("scope", System.getenv("AZURE_APP_ID_URI") + "/.default")
            );
            case "aws-cognito" -> new ProviderConfig(
                "https://" + System.getenv("COGNITO_DOMAIN") + "/oauth2/token",
                Map.of()
            );
            default -> throw new IllegalArgumentException("Unknown provider: " + provider);
        };
    }

    // ============================================================================
    // Unified token fetcher (handles provider differences)
    // ============================================================================

    public TokenResult fetchClientCredentialsToken(
            String provider, String clientId, String clientSecret, String scope) {
        var config = getProviderConfig(provider);

        var params = new LinkedMultiValueMap<String, String>();
        params.add("grant_type", "client_credentials");
        params.add("client_id", clientId);
        params.add("client_secret", clientSecret);
        params.add("scope", scope);
        config.extraParams().forEach(params::add);

        var response = restClient.post()
            .uri(config.tokenUrl())
            .contentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED)
            .body(params)
            .retrieve()
            .body(Map.class);

        return new TokenResult(
            (String) response.get("access_token"),
            (Integer) response.get("expires_in")
        );
    }
}
import os
import aiohttp
from dataclasses import dataclass, field
from typing import Literal

# ============================================================================
# Provider-specific configurations
# ============================================================================

Provider = Literal["keycloak", "auth0", "azure-ad", "aws-cognito"]

@dataclass
class ProviderConfig:
    token_url: str
    extra_params: dict[str, str] = field(default_factory=dict)

@dataclass
class TokenResult:
    access_token: str
    expires_in: int

def get_provider_config(provider: Provider) -> ProviderConfig:
    match provider:
        case "keycloak":
            return ProviderConfig(
                token_url=(
                    f"{os.environ['KEYCLOAK_URL']}/realms/"
                    f"{os.environ['REALM']}/protocol/openid-connect/token"
                )
            )
        case "auth0":
            return ProviderConfig(
                token_url=f"https://{os.environ['AUTH0_DOMAIN']}/oauth/token",
                extra_params={"audience": os.environ["AUTH0_AUDIENCE"]},
            )
        case "azure-ad":
            return ProviderConfig(
                token_url=(
                    f"https://login.microsoftonline.com/"
                    f"{os.environ['TENANT_ID']}/oauth2/v2.0/token"
                ),
                extra_params={
                    # Azure AD uses /.default scope for pre-configured permissions
                    "scope": f"{os.environ['AZURE_APP_ID_URI']}/.default"
                },
            )
        case "aws-cognito":
            return ProviderConfig(
                token_url=f"https://{os.environ['COGNITO_DOMAIN']}/oauth2/token"
            )

# ============================================================================
# Unified token fetcher (handles provider differences)
# ============================================================================

async def fetch_client_credentials_token(
    provider: Provider,
    client_id: str,
    client_secret: str,
    scope: str,
) -> TokenResult:
    config = get_provider_config(provider)

    params = {
        "grant_type": "client_credentials",
        "client_id": client_id,
        "client_secret": client_secret,
        "scope": scope,
        **config.extra_params,
    }

    async with aiohttp.ClientSession() as session:
        async with session.post(
            config.token_url,
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        ) as resp:
            resp.raise_for_status()
            data = await resp.json()

    return TokenResult(
        access_token=data["access_token"],
        expires_in=data["expires_in"],
    )
using System.Net.Http.Json;

// ============================================================================
// Provider-specific configurations
// ============================================================================

public record ProviderConfig(string TokenUrl, Dictionary<string, string> ExtraParams);
public record TokenResult(string AccessToken, int ExpiresIn);

public class MultiProviderTokenService
{
    private readonly HttpClient _httpClient;

    public MultiProviderTokenService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public ProviderConfig GetProviderConfig(string provider) => provider switch
    {
        "keycloak" => new ProviderConfig(
            $"{Environment.GetEnvironmentVariable("KEYCLOAK_URL")}/realms/"
            + $"{Environment.GetEnvironmentVariable("REALM")}/protocol/openid-connect/token",
            new Dictionary<string, string>()
        ),
        "auth0" => new ProviderConfig(
            $"https://{Environment.GetEnvironmentVariable("AUTH0_DOMAIN")}/oauth/token",
            new Dictionary<string, string>
            {
                ["audience"] = Environment.GetEnvironmentVariable("AUTH0_AUDIENCE")!
            }
        ),
        "azure-ad" => new ProviderConfig(
            $"https://login.microsoftonline.com/"
            + $"{Environment.GetEnvironmentVariable("TENANT_ID")}/oauth2/v2.0/token",
            new Dictionary<string, string>
            {
                // Azure AD uses /.default scope for all pre-configured permissions
                ["scope"] = $"{Environment.GetEnvironmentVariable("AZURE_APP_ID_URI")}/.default"
            }
        ),
        "aws-cognito" => new ProviderConfig(
            $"https://{Environment.GetEnvironmentVariable("COGNITO_DOMAIN")}/oauth2/token",
            new Dictionary<string, string>()
        ),
        _ => throw new ArgumentException($"Unknown provider: {provider}")
    };

    // ============================================================================
    // Unified token fetcher (handles provider differences)
    // ============================================================================

    public async Task<TokenResult> FetchClientCredentialsTokenAsync(
        string provider, string clientId, string clientSecret, string scope)
    {
        var config = GetProviderConfig(provider);

        var formData = new Dictionary<string, string>
        {
            ["grant_type"] = "client_credentials",
            ["client_id"] = clientId,
            ["client_secret"] = clientSecret,
            ["scope"] = scope,
        };

        foreach (var (key, value) in config.ExtraParams)
            formData[key] = value;

        var response = await _httpClient.PostAsync(
            config.TokenUrl, new FormUrlEncodedContent(formData));
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadFromJsonAsync<JsonElement>();
        return new TokenResult(
            data.GetProperty("access_token").GetString()!,
            data.GetProperty("expires_in").GetInt32()
        );
    }
}

Part 7: Production Checklist

Authorization Server Setup

  • Each service has its own client_id — never share credentials between services
  • client_secret stored in a secrets manager (Vault, AWS Secrets Manager), not env vars in code
  • Scopes defined with minimum permissions per service pair
  • Audience claim set to the specific downstream service URL
  • Token TTL set to 1 hour or less
  • Authorization server has high availability (multiple instances, health checks)

Token Management

  • Tokens are cached until within 60 seconds of expiry
  • Single-flight pattern prevents concurrent token fetch storms
  • On 401 response: invalidate cache and retry exactly once
  • Token fetch failures have retry with exponential backoff
  • Never log access tokens (even partially)

Token Validation

  • Validate iss (issuer) matches the expected authorization server
  • Validate aud (audience) matches the service’s own identifier
  • Validate exp — tokens are not accepted after expiry
  • Validate scopes before allowing access to protected resources
  • Use JWKS endpoint for key discovery — handles key rotation automatically
  • JWKS responses are cached (jose library handles this automatically)

Operational

  • Client secret rotation procedure documented and tested
  • Monitor token fetch error rates per service
  • Alert on authorization server latency spikes (affects all service calls)
  • Alert on token validation failure spikes (potential compromise or misconfiguration)
  • Audit log: which service called which endpoint with which scopes
  • Plan for authorization server downtime: services should use cached tokens

Conclusion

OAuth 2.0 Client Credentials is the most production-mature pattern for service-to-service authentication when you need scope-based access control. The ecosystem is enormous — Keycloak, Auth0, Okta, Azure AD, AWS Cognito all implement it. Any API gateway, service mesh, or monitoring tool knows how to work with Bearer tokens.

The tricky parts are operational: keeping the authorization server highly available (it’s in the critical path of every service call), implementing token caching correctly to avoid performance cliffs, and managing the complexity of scopes and audiences as the service graph grows.

Start with short-lived tokens (1 hour), cache aggressively, validate audience and scopes on every request, and treat the authorization server as your most critical piece of infrastructure. Get all of that right, and OAuth 2.0 client credentials will scale with you from 5 services to 500.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง