This document presents a production-ready low-level design for implementing an OAuth 2.0 service that handles token lifecycle management, client validation, and secure authorization flows for modern distributed systems.
1. Problem Statement & Requirements
Clarifying questions
- What scale and traffic expectations are we targeting?
- Is this a service for millions of users with high concurrent request volume (e.g., 100k+ RPS) or a smaller-scale internal auth system?
- What consistency and availability SLAs are required?
- Should the OAuth service support sub-100ms response times (p99 < 200ms) with 99.9% availability?
- What are the key entities and their relationships?
- Are we designing for a single tenant or multi-tenant model? What are the relationships between users, clients, tokens, scopes?
- What's the read/write ratio for token and user operations?
- Is it primarily token issuance (write-heavy) or frequent token validation (read-heavy)?
- How sensitive is the system to latency?
- Are there strict requirements for token validation to be sub-50ms in all cases?
- What authentication model does the system support?
- Should it support only OAuth2 flows (Authorization Code, Implicit, Client Credentials) or also OpenID Connect?
- Are there external integrations with identity providers (IdPs) or SSO systems?
- Will the service be federating with Okta, Auth0, or other IdPs?
Functional requirements
- Support OAuth2 flows (Authorization Code, Implicit, Client Credentials, Refresh Token)
- Issue and validate JWT access tokens and refresh tokens
- Manage OAuth2 clients (registration, configuration, scopes)
- Support OpenID Connect (OIDC) for identity assertions
- Allow token revocation and refresh
- Support PKCE (Proof Key for Code Exchange) for public clients
- Provide token introspection endpoint to validate tokens
- Support scope-based access control (RBAC-style)
- Allow client and user consent management
Non-functional requirements
| Requirement | Target |
|---|---|
| p50 latency (token issuance) | < 50ms |
| p95 latency (token issuance) | < 100ms |
| p99 latency (token issuance) | < 200ms |
| Throughput | 100k+ RPS (peak) |
| Availability | 99.9% (99.99% for critical flows) |
| Token validation latency | < 50ms (p99) |
| Concurrent token issuance | Support up to 10k concurrent users |
Constraints and assumptions
- The system is designed for a multi-tenant SaaS environment
- Authentication is handled via an external identity provider (e.g., Okta or Azure AD) or internal user store
- Token validation must be fast and stateless where possible (e.g., JWT-based)
- The system assumes a centralized database for storing client, user, and token metadata
- All tokens are expected to have TTLs (e.g., 1 hour for access, 30 days for refresh)
- Token rotation is not required in this design (but can be added later)
Out of scope
- User registration or password reset flows
- Multi-factor authentication (MFA) support
- Token encryption at rest (only JWTs with signing are supported)
- OAuth2 delegation or proxy flows (e.g., SAML to OAuth2 bridging)
- Token revocation via Webhooks or push notifications
- Integration with external identity providers (e.g., SAML, LDAP) beyond basic user store access
- Support for OAuth2 device flow or resource owner password credentials (ROPC) flows
2. API Design
API Style Selection
gRPC with Protobuf is selected for the OAuth service due to:
- Strong typing and schema evolution support
- Efficient binary serialization reducing payload size
- Built-in support for streaming and bidirectional communication
- Native support for authentication and authorization headers
- Better performance characteristics for microservices communication
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /oauth/v1/token | Exchange authorization code for access token | None |
| POST | /oauth/v1/refresh | Refresh access token using refresh token | None |
| GET | /oauth/v1/authorize | Authorization code request endpoint | User Auth |
| POST | /oauth/v1/revoke | Revoke access/refresh token | User Auth |
| GET | /oauth/v1/userinfo | Get authenticated user info | Bearer Token |
| POST | /oauth/v1/client | Register new OAuth client | Admin Auth |
| PUT | /oauth/v1/client/{id} | Update existing OAuth client | Admin Auth |
| DELETE | /oauth/v1/client/{id} | Delete OAuth client | Admin Auth |
Request/Response Schemas
Token Request (gRPC)
message TokenRequest {
string grant_type = 1;
string client_id = 2;
string client_secret = 3;
string code = 4;
string redirect_uri = 5;
string refresh_token = 6;
}
message TokenResponse {
string access_token = 1;
string token_type = 2;
int32 expires_in = 3;
string refresh_token = 4;
string scope = 5;
}
Client Registration Request (gRPC)
message ClientRequest {
string client_name = 1;
string client_uri = 2;
repeated string redirect_uris = 3;
string logo_uri = 4;
string client_type = 5; // "public" or "confidential"
repeated string grant_types = 6;
repeated string response_types = 7;
string token_endpoint_auth_method = 8; // "client_secret_basic", "client_secret_post", "none"
}
User Info Response (gRPC)
message UserInfoResponse {
string sub = 1; // Subject identifier
string name = 2;
string email = 3;
string preferred_username = 4;
google.protobuf.Timestamp updated_at = 5;
}
HTTP Status Codes & Error Response
Standard HTTP status codes:
200 OK- Successful response201 Created- Resource created400 Bad Request- Invalid request parameters401 Unauthorized- Authentication required or invalid credentials403 Forbidden- Insufficient permissions404 Not Found- Resource not found429 Too Many Requests- Rate limiting exceeded
Standardized error response format:
{
"error": {
"code": "INVALID_GRANT",
"message": "The authorization code is invalid or expired",
"details": {
"field": "code",
"value": "expired_code"
}
}
}
API Versioning Strategy
Versioned using path prefix: /oauth/v1/... with support for:
- Backward compatibility within same major version
- Migration path documented in changelog
- Deprecation policy with 6-month notice period
Pagination Design
Cursor-based pagination selected because:
- More efficient for large datasets
- Consistent results even with concurrent writes
- Simpler to implement and maintain
Authentication & Authorization
Authentication:
- OAuth 2.0 Bearer Token for all protected endpoints
- Client credentials for client registration and management
Authorization:
- JWT-based claims verification for user scopes
- Admin role required for client management endpoints
- Scope validation per endpoint (e.g.,
read:userfor/userinfo)
Rate Limiting
Per Endpoint Limits:
| Endpoint | Limit | Period |
|---|---|---|
/oauth/v1/token | 1000 req/min | 1 minute |
/oauth/v1/refresh | 500 req/min | 1 minute |
/oauth/v1/revoke | 200 req/min | 1 minute |
/oauth/v1/userinfo | 5000 req/min | 1 minute |
/oauth/v1/client | 100 req/min | 1 minute |
Enforcement Strategy:
- Token bucket algorithm with leaky bucket refinement
- Distributed rate limiting using Redis with atomic operations
Idempotency Design
Idempotency Key Strategy:
X-Idempotency-Keyheader for all mutation endpoints- Store in Redis with TTL of 5 minutes
- Return
X-Idempotency-Resultheader indicating if operation was replayed
// Example usage in service layer
public TokenResponse handleTokenRequest(TokenRequest request, String idempotencyKey) {
if (idempotencyKey != null) {
String cachedResult = redisTemplate.get(idempotencyKey);
if (cachedResult != null) {
return parseCachedResponse(cachedResult);
}
}
// Process request...
TokenResponse response = processRequest(request);
if (idempotencyKey != null) {
redisTemplate.setex(idempotencyKey, 300, serializeResponse(response));
}
return response;
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
USER {
string id PK
string email
string hashed_password
boolean is_email_verified
timestamp created_at
timestamp updated_at
timestamp last_login_at
}
CLIENT {
string id PK
string secret
string name
string redirect_uri
string logo_url
boolean is_active
timestamp created_at
timestamp updated_at
}
AUTHORIZATION_CODE {
string id PK
string user_id FK
string client_id FK
string scope
timestamp expires_at
timestamp created_at
boolean is_used
}
ACCESS_TOKEN {
string id PK
string user_id FK
string client_id FK
string scope
timestamp expires_at
timestamp created_at
boolean is_revoked
}
REFRESH_TOKEN {
string id PK
string user_id FK
string client_id FK
timestamp expires_at
timestamp created_at
boolean is_revoked
}
OAUTH_SESSION {
string id PK
string user_id FK
string client_id FK
string state
string nonce
string redirect_uri
timestamp created_at
timestamp expires_at
}
USER_CONSENT {
string id PK
string user_id FK
string client_id FK
string scope
timestamp granted_at
}
USER <-o-- OAUTH_SESSION : "owns"
CLIENT <-o-- OAUTH_SESSION : "owns"
USER <-o-- AUTHORIZATION_CODE : "owns"
CLIENT <-o-- AUTHORIZATION_CODE : "owns"
USER <-o-- ACCESS_TOKEN : "owns"
CLIENT <-o-- ACCESS_TOKEN : "owns"
USER <-o-- REFRESH_TOKEN : "owns"
CLIENT <-o-- REFRESH_TOKEN : "owns"
USER <-o-- USER_CONSENT : "owns"
CLIENT <-o-- USER_CONSENT : "owns"
Table Schema Definitions
Users Table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
is_email_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP WITH TIME ZONE,
INDEX idx_users_email (email),
INDEX idx_users_created_at (created_at)
);
OAuth Clients Table
CREATE TABLE oauth_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
secret TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
logo_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_oauth_clients_active (is_active),
INDEX idx_oauth_clients_created_at (created_at)
);
OAuth Sessions Table
CREATE TABLE oauth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
state VARCHAR(255) NOT NULL,
nonce VARCHAR(255),
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
INDEX idx_oauth_sessions_user_client (user_id, client_id),
INDEX idx_oauth_sessions_expires_at (expires_at),
INDEX idx_oauth_sessions_created_at (created_at)
);
Authorization Codes Table
CREATE TABLE authorization_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
scope TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN DEFAULT FALSE,
INDEX idx_auth_codes_user_client (user_id, client_id),
INDEX idx_auth_codes_expires_at (expires_at),
INDEX idx_auth_codes_used (is_used)
);
Access Tokens Table
CREATE TABLE access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
scope TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_revoked BOOLEAN DEFAULT FALSE,
INDEX idx_access_tokens_user_client (user_id, client_id),
INDEX idx_access_tokens_expires_at (expires_at),
INDEX idx_access_tokens_revoked (is_revoked)
);
Refresh Tokens Table
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_revoked BOOLEAN DEFAULT FALSE,
INDEX idx_refresh_tokens_user_client (user_id, client_id),
INDEX idx_refresh_tokens_expires_at (expires_at),
INDEX idx_refresh_tokens_revoked (is_revoked)
);
User Consents Table
CREATE TABLE user_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
scope TEXT NOT NULL,
granted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_consents_user_client (user_id, client_id),
INDEX idx_user_consents_granted_at (granted_at)
);
Primary Key Strategy
UUIDs are used as primary keys for all tables. This choice provides:
- Scalability: No need to coordinate across shards or databases
- Security: No sequential predictability that could be exploited
- Distributed systems: Easier to merge data from different sources without ID conflicts
- No hotspots: Avoids write contention in clustered environments
Index Design
| Table | Index Name | Fields | Purpose |
|---|---|---|---|
| users | idx_users_email | Fast email lookups | |
| users | idx_users_created_at | created_at | Audit and reporting |
| oauth_clients | idx_oauth_clients_active | is_active | Fast client filtering |
| oauth_sessions | idx_oauth_sessions_user_client | user_id, client_id | Session lookup by user/client |
| oauth_sessions | idx_oauth_sessions_expires_at | expires_at | Cleanup of expired sessions |
| authorization_codes | idx_auth_codes_user_client | user_id, client_id | Fast code lookup |
| authorization_codes | idx_auth_codes_expires_at | expires_at | Cleanup of expired codes |
| access_tokens | idx_access_tokens_user_client | user_id, client_id | Fast token lookup |
| access_tokens | idx_access_tokens_expires_at | expires_at | Cleanup of expired tokens |
| refresh_tokens | idx_refresh_tokens_user_client | user_id, client_id | Fast refresh token lookup |
| refresh_tokens | idx_refresh_tokens_expires_at | expires_at | Cleanup of expired tokens |
| user_consents | idx_user_consents_user_client | user_id, client_id | Fast consent lookup |
Data Partitioning / Sharding Strategy
Shard by User ID: OAuth data is naturally user-centric, so partitioning by user_id provides:
- User isolation: All OAuth data for a user stays together
- Query locality: User-specific operations are fast and localized
- Scalability: Easy to scale per user volume
Soft Delete vs. Hard Delete Decision
Soft delete is used for all tables to support:
- Audit trails: Historical tracking of user consent and token usage
- Compliance: GDPR compliance for data retention requirements
- Recovery capability: Ability to restore deleted records if needed
- Analytics: Historical analysis of user behavior patterns
Audit Trail Design
All tables have created_at and updated_at timestamps. Additionally:
- User activity logs: Track login times, consent grants, and token creation
- Admin actions: Log when clients are activated/deactivated or scopes changed
- Event-based audit: Use a separate
audit_eventstable for detailed tracking
Data Archival Strategy and Retention Policy
- Access tokens: 30 days retention (after revocation)
- Refresh tokens: 90 days retention (after revocation)
- Authorization codes: 15 minutes retention (after use or expiration)
- User consents: Indefinite retention (user can revoke at any time)
- OAuth sessions: 1 hour retention (after completion or expiration)
Archival process:
- Move expired tokens to archive tables
- Log archival events for compliance
- Purge archived data after 90 days
4. Class Design & Service Layer
classDiagram
class OAuthService {
+authenticateUser(username, password)
+generateToken(user)
+validateToken(token)
+revokeToken(token)
+refreshToken(refreshToken)
}
class TokenService {
+generateAccessToken(user, scope)
+generateRefreshToken(user)
+validateAccessToken(token)
+validateRefreshToken(token)
+revokeToken(token)
}
class UserService {
+getUserById(id)
+getUserByUsername(username)
+validateCredentials(username, password)
}
class TokenRepository {
+saveToken(token)
+findTokenByValue(value)
+revokeToken(token)
+findTokensByUserId(userId)
}
class AuthCodeService {
+generateAuthCode(user, clientId, scope)
+validateAuthCode(code)
+consumeAuthCode(code)
}
class AuthCodeRepository {
+saveAuthCode(authCode)
+findAuthCodeByValue(value)
+deleteAuthCode(code)
}
class ClientService {
+getClientById(id)
+validateClientSecret(clientId, secret)
}
class EventPublisher {
+publishTokenGeneratedEvent(token)
+publishTokenRevokedEvent(token)
}
OAuthService --> TokenService : uses
OAuthService --> UserService : uses
OAuthService --> AuthCodeService : uses
OAuthService --> ClientService : uses
TokenService --> TokenRepository : persists
AuthCodeService --> AuthCodeRepository : persists
OAuthService --> EventPublisher : publishes
class Token {
-id: UUID
-value: String
-type: TokenType
-userId: UUID
-clientId: UUID
-expiresAt: Instant
-createdAt: Instant
-revokedAt: Instant
}
class AuthCode {
-id: UUID
-value: String
-userId: UUID
-clientId: UUID
-scope: String
-expiresAt: Instant
-createdAt: Instant
-consumed: boolean
}
class User {
-id: UUID
-username: String
-passwordHash: String
-email: String
-isActive: boolean
}
class Client {
-id: UUID
-clientId: String
-clientSecret: String
-redirectUri: String
-name: String
-isActive: boolean
}
class TokenType {
ACCESS_TOKEN
REFRESH_TOKEN
}
stateDiagram-v2
[*] --> Active
Active --> Revoked : Token Revoked
Active --> Expired : Token Expires
Revoked --> [*]
Expired --> [*]
Service Class Structure and Responsibilities
OAuthService: Main entry point for OAuth operations. Handles authentication flow, token generation, validation, and revocation.
TokenService: Manages token lifecycle including generation, validation, and revocation logic.
UserService: Handles user authentication and identity verification.
AuthCodeService: Manages authorization code flow operations.
ClientService: Validates client credentials and manages OAuth clients.
Interface / Abstract Class Design
public interface TokenGenerator {
Token generateToken(User user, String scope);
}
public interface TokenValidator {
boolean validateToken(String token);
Token getValidToken(String token);
}
public interface TokenRepository {
void save(Token token);
Token findByValue(String value);
void revoke(Token token);
List<Token> findByUserId(UUID userId);
}
public interface AuthCodeRepository {
void save(AuthCode authCode);
AuthCode findByValue(String value);
void delete(String code);
}
Business Logic Encapsulation Patterns
Strategy Pattern: Token generation strategies for different grant types.
public enum GrantType {
PASSWORD,
CLIENT_CREDENTIALS,
AUTHORIZATION_CODE,
REFRESH_TOKEN
}
public interface TokenGenerationStrategy {
Token generateToken(OAuthContext context);
}
@Component
public class PasswordGrantStrategy implements TokenGenerationStrategy {
private final UserService userService;
private final TokenService tokenService;
@Override
public Token generateToken(OAuthContext context) {
User user = userService.authenticate(
context.getUsername(),
context.getPassword()
);
return tokenService.generateAccessToken(user, context.getScope());
}
}
@Component
public class AuthorizationCodeGrantStrategy implements TokenGenerationStrategy {
private final AuthCodeService authCodeService;
private final TokenService tokenService;
@Override
public Token generateToken(OAuthContext context) {
AuthCode authCode = authCodeService.validateAndConsume(
context.getAuthorizationCode()
);
return tokenService.generateAccessToken(
authCode.getUserId(),
authCode.getScope()
);
}
}
Critical-Path Method Implementation
@Transactional
public Token authenticateUser(String username, String password) {
// Validate input parameters
if (username == null || password == null) {
throw new InvalidRequestException("Username and password are required");
}
// Authenticate user credentials
User user = userService.validateCredentials(username, password);
if (user == null) {
throw new AuthenticationException("Invalid credentials");
}
// Check if user is active
if (!user.isActive()) {
throw new AuthenticationException("User account is inactive");
}
// Generate access and refresh tokens
Token accessToken = tokenService.generateAccessToken(user, "read write");
Token refreshToken = tokenService.generateRefreshToken(user);
// Store tokens in database
tokenRepository.save(accessToken);
tokenRepository.save(refreshToken);
// Publish events for observability
eventPublisher.publishTokenGeneratedEvent(accessToken);
eventPublisher.publishTokenGeneratedEvent(refreshToken);
// Return the access token for immediate use
return accessToken;
}
Transaction Boundary Design
Single transaction encompasses:
- User authentication validation
- Token generation and persistence
- Event publishing (if using same transaction)
- Session invalidation for concurrent logins
Service Interface Contracts
public interface OAuthService {
Token authenticateUser(String username, String password);
Token validateToken(String token);
void revokeToken(String token);
Token refreshToken(String refreshToken);
}
Saga / Orchestration Pattern
For complex flows like refresh token rotation, a saga pattern is used to ensure consistency across distributed systems.
Domain Object / Value Object Design
public class Token {
private final UUID id;
private final String value;
private final TokenType type;
private final UUID userId;
private final UUID clientId;
private final Instant expiresAt;
private final Instant createdAt;
private Instant revokedAt;
public Token(UUID id, String value, TokenType type, UUID userId,
UUID clientId, Instant expiresAt, Instant createdAt) {
this.id = id;
this.value = value;
this.type = type;
this.userId = userId;
this.clientId = clientId;
this.expiresAt = expiresAt;
this.createdAt = createdAt;
}
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
public boolean isRevoked() {
return revokedAt != null;
}
public void revoke() {
this.revokedAt = Instant.now();
}
}
public enum TokenType {
ACCESS_TOKEN,
REFRESH_TOKEN
}
5. Concurrency & Thread Safety
Concurrency Model
The OAuth service will use a thread pool-based concurrency model with Java's CompletableFuture and ExecutorService for handling asynchronous operations. The service will leverage the Reactor framework (via Spring WebFlux) for non-blocking I/O operations, allowing efficient handling of concurrent authentication requests without blocking threads. For synchronous operations, the service will use a fixed-size thread pool with size tuned based on CPU cores and I/O characteristics.
Race Condition Prevention Strategy
Race conditions will be prevented through:
- Database-level atomic operations for token generation and refresh
- Optimistic locking using version fields in entities to detect concurrent modifications
- Database transactions with proper isolation levels (READ_COMMITTED or REPEATABLE_READ)
- Application-level synchronization for critical sections using
ReentrantLockorStampedLockwhere necessary
Optimistic vs. Pessimistic Locking Selection
Optimistic locking is selected for most operations because:
- High read-to-write ratio in OAuth flows (token validation is more frequent than creation)
- Lower contention and better scalability
- Reduced blocking overhead in concurrent environments
Pessimistic locking is used for:
- Token revocation operations where immediate consistency is required
- Session creation/deletion operations that must not overlap
Deadlock Prevention Strategy
Deadlock prevention will be implemented through:
- Lock ordering - All operations acquire locks in a consistent order (e.g., by client ID, then user ID)
- Timeout-based acquisition - Locks will timeout after 50ms to prevent indefinite blocking
- Hierarchical lock management - Locks will be grouped by scope (client, user, session)
- Deadlock detection - Periodic check of lock wait chains with automatic timeout handling
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency will be handled through:
- Idempotency keys for token creation endpoints (e.g.,
POST /oauth/token) - Database uniqueness constraints on idempotency key + operation type combinations
- Cache-based idempotency - Store recent idempotency keys in Redis with TTL of 5 minutes
- Request deduplication - Use distributed locks to ensure only one request processes per idempotency key
// Example implementation of idempotent token creation with optimistic locking
public class OAuthTokenService {
private final IdempotencyCache idempotencyCache;
private final TokenRepository tokenRepository;
private final LockManager lockManager;
public OAuthTokenResponse createTokenWithIdempotency(
TokenRequest request,
String idempotencyKey) {
// Check if this operation was already completed
String cachedResponse = idempotencyCache.get(idempotencyKey);
if (cachedResponse != null) {
return parseCachedResponse(cachedResponse);
}
// Acquire distributed lock for idempotency key
try (DistributedLock lock = lockManager.acquire(idempotencyKey, 5000)) {
// Double-check cache after acquiring lock
cachedResponse = idempotencyCache.get(idempotencyKey);
if (cachedResponse != null) {
return parseCachedResponse(cachedResponse);
}
// Perform the actual token creation with optimistic locking
OAuthToken token = createTokenWithOptimisticLocking(request);
// Cache response for idempotency
String response = serializeResponse(token);
idempotencyCache.put(idempotencyKey, response, Duration.ofMinutes(5));
return token;
} catch (LockAcquisitionException e) {
throw new IdempotencyException("Request already being processed");
}
}
private OAuthToken createTokenWithOptimisticLocking(TokenRequest request) {
// Use database transaction with version field for optimistic locking
return tokenRepository.withTransaction(() -> {
// Validate request and check existing tokens
OAuthToken existing = tokenRepository.findByClientAndUser(
request.getClientId(),
request.getUserId()
);
if (existing != null && !isExpired(existing)) {
return existing;
}
// Create new token with version tracking
OAuthToken newToken = createNewToken(request);
// Update with optimistic locking - will throw exception if version changed
tokenRepository.saveWithOptimisticLocking(newToken);
return newToken;
});
}
}
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant U as User
participant OAuth as OAuth Service
participant DB as Database
participant AuthN as AuthN Provider
participant AuthZ as AuthZ Service
U->>OAuth: 1. Initiate OAuth Flow (GET /authorize)
OAuth->>U: 2. Redirect to AuthN with params
U->>AuthN: 3. User authenticates
AuthN->>OAuth: 4. Redirect back with code
OAuth->>DB: 5. Validate and store auth code
OAuth->>AuthN: 6. Exchange code for tokens
AuthN-->>OAuth: 7. Access and Refresh Tokens
OAuth->>DB: 8. Store tokens and user info
OAuth->>U: 9. Return access token to client
Note over U,OAuth: Happy Path - Successful Authorization Flow
sequenceDiagram
participant U as User
participant OAuth as OAuth Service
participant DB as Database
participant AuthN as AuthN Provider
U->>OAuth: 1. Request with invalid code (POST /token)
OAuth->>DB: 2. Validate auth code
DB-->>OAuth: 3. Code not found or expired
OAuth->>U: 4. Return 400 Bad Request with error details
Note over U,OAuth: Error Path - Invalid Auth Code
sequenceDiagram
participant Client as OAuth Client
participant OAuth as OAuth Service
participant DB as Database
participant TokenService as Token Validation Service
Client->>OAuth: 1. Request with access token (GET /resource)
OAuth->>TokenService: 2. Validate JWT token
TokenService->>DB: 3. Check if token is revoked
DB-->>TokenService: 4. Token not found in revocation list
TokenService-->>OAuth: 5. Token is valid and active
OAuth->>Client: 6. Return protected resource
Note over Client,OAuth: Async Flow - Token Validation and Resource Access
7. Caching Design
Cache Strategy Selection
For an OAuth service, we'll use cache-aside pattern with write-through for critical session data and cache-aside for less critical metadata. This approach provides the best balance between consistency, performance, and complexity.
Cache Key Design
Cache keys will follow a namespace strategy to avoid collisions and enable easy invalidation:
public class CacheKeyBuilder {
public static String buildTokenKey(String tokenType, String tokenId) {
return String.format("oauth:%s:%s", tokenType, tokenId);
}
public static String buildUserSessionKey(String userId) {
return String.format("oauth:sessions:user:%s", userId);
}
public static String buildClientKey(String clientId) {
return String.format("oauth:client:%s", clientId);
}
public static String buildTokenInfoKey(String token) {
return String.format("oauth:token_info:%s", token);
}
}
TTL Configuration
| Data Type | TTL | Reason |
|---|---|---|
| Access Tokens | 3600s (1 hour) | Standard OAuth2 token lifetime |
| Refresh Tokens | 86400s (24 hours) | Longer-lived for refresh capability |
| User Sessions | 1800s (30 minutes) | Session timeout for security |
| Client Metadata | 3600s (1 hour) | Relatively static client info |
| Token Info Cache | 300s (5 minutes) | Frequent access but not critical |
Eviction Policy
We'll use LRU (Least Recently Used) eviction policy with a secondary TTL-based cleanup to handle edge cases where LRU might not be optimal for time-sensitive data.
Cache Stampede Prevention
To prevent cache stampedes:
- Probabilistic early expiry: Set cache TTL to 80% of actual TTL and use a random jitter factor
- Distributed locks: Use Redis distributed locks for cache population operations
public class CacheStampedePrevention {
private static final double JITTER_FACTOR = 0.1;
public boolean acquireLock(String key, int timeoutSeconds) {
String lockKey = "lock:" + key;
String lockValue = UUID.randomUUID().toString();
// Try to acquire distributed lock
boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, timeoutSeconds, TimeUnit.SECONDS);
return acquired;
}
public void releaseLock(String key, String lockValue) {
String lockKey = "lock:" + key;
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
}
}
Distributed Cache Architecture
We'll use Redis Cluster with:
- 3 master nodes for high availability
- 3 replica nodes per master for read scaling
- Redis Sentinel for automatic failover monitoring
- Cache sharding based on token type to distribute load
Cache Invalidation Strategy
We'll implement event-driven invalidation for critical data:
@Component
public class OAuthCacheInvalidator {
@EventListener
public void handleTokenRevocation(TokenRevokedEvent event) {
String tokenKey = CacheKeyBuilder.buildTokenKey("access", event.getTokenId());
redisTemplate.delete(tokenKey);
// Invalidate associated session if exists
String sessionKey = CacheKeyBuilder.buildUserSessionKey(event.getUserId());
redisTemplate.delete(sessionKey);
}
@EventListener
public void handleClientUpdate(ClientUpdatedEvent event) {
String clientKey = CacheKeyBuilder.buildClientKey(event.getClientId());
redisTemplate.delete(clientKey);
}
}
Cache Hit/Miss Metrics
We'll track cache performance with:
oauth.cache.hits(counter)oauth.cache.misses(counter)oauth.cache.hit_ratio(gauge)
@Component
public class CacheMetrics {
private final MeterRegistry meterRegistry;
public void recordCacheHit(String cacheType) {
Counter.builder("oauth.cache.hits")
.tag("type", cacheType)
.register(meterRegistry)
.increment();
}
public void recordCacheMiss(String cacheType) {
Counter.builder("oauth.cache.misses")
.tag("type", cacheType)
.register(meterRegistry)
.increment();
}
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The OAuth service follows the repository pattern to abstract data access logic and provide a clean interface for domain operations. Each entity has its own repository with standardized CRUD operations and specialized query methods.
public interface UserRepository {
Optional<User> findById(UUID id);
Optional<User> findByEmail(String email);
User save(User user);
void delete(UUID id);
List<User> findAll();
}
public interface OAuthClientRepository {
Optional<OAuthClient> findById(String id);
List<OAuthClient> findByUserId(UUID userId);
OAuthClient save(OAuthClient client);
void delete(String id);
}
public interface AccessTokenRepository {
Optional<AccessToken> findByToken(String token);
List<AccessToken> findByUserId(UUID userId);
AccessToken save(AccessToken token);
void delete(String token);
List<AccessToken> findValidByUserId(UUID userId);
}
ORM vs. Raw Query Decisions
We use JPA/Hibernate for most entities due to its maturity, type safety, and ease of maintenance. However, complex analytical queries or performance-critical operations use raw SQL:
@Repository
public class AccessTokenRepositoryImpl implements AccessTokenRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<AccessToken> findValidByUserId(UUID userId) {
// Using JPA for simple queries
return entityManager.createQuery(
"SELECT t FROM AccessToken t WHERE t.userId = :userId AND t.expiry > CURRENT_TIMESTAMP",
AccessToken.class)
.setParameter("userId", userId)
.getResultList();
}
@Override
public List<AccessToken> findExpiredTokens(LocalDateTime cutoff) {
// Using raw SQL for performance-critical batch operations
String sql = """
SELECT * FROM access_tokens
WHERE expiry < :cutoff AND revoked = false
ORDER BY expiry ASC
LIMIT 1000
""";
return entityManager.createNativeQuery(sql, AccessToken.class)
.setParameter("cutoff", cutoff)
.getResultList();
}
}
Read vs. Write Separation
We implement CQRS for read-heavy operations to optimize performance:
// Read model - optimized for querying
public class OAuthClientReadModel {
private String id;
private String name;
private String redirectUri;
private boolean enabled;
// getters and setters
}
// Write model - for mutations
public class OAuthClient {
private String id;
private String name;
private String secret;
private String redirectUri;
private boolean enabled;
private LocalDateTime createdAt;
// getters and setters
}
// Read repository - optimized for reads
@Repository
public class OAuthClientReadRepository {
@Query("SELECT new com.example.oauth.model.OAuthClientReadModel(c.id, c.name, c.redirectUri, c.enabled) " +
"FROM OAuthClient c WHERE c.userId = :userId")
List<OAuthClientReadModel> findByUserId(@Param("userId") UUID userId);
}
Connection Pool Configuration
We configure HikariCP with optimal settings for high-concurrency OAuth operations:
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 5000
leak-detection-threshold: 60000
N+1 Query Prevention Strategy
We implement eager loading and batch fetching to prevent N+1 issues:
@Entity
@Table(name = "oauth_clients")
@NamedEntityGraph(
name = "OAuthClient.withUserAndTokens",
attributeNodes = @NamedAttributeNode(value = "user", subgraph = "tokens"),
subgraphs = @NamedSubgraph(
name = "tokens",
attributeNodes = @NamedAttributeNode("accessTokens")
)
)
public class OAuthClient {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToMany(mappedBy = "client", fetch = FetchType.LAZY)
@BatchSize(size = 20)
private List<AccessToken> accessTokens = new ArrayList<>();
// getters and setters
}
// Query that fetches all related data in minimal queries:
@Query("SELECT c FROM OAuthClient c JOIN FETCH c.user u LEFT JOIN FETCH c.accessTokens t WHERE c.userId = :userId")
List<OAuthClient> findByUserIdWithTokens(@Param("userId") UUID userId);
Batch Operation Design
For bulk operations like token cleanup, we implement batch processing:
@Service
@Transactional
public class AccessTokenBatchService {
@PersistenceContext
private EntityManager entityManager;
public int cleanupExpiredTokens(LocalDateTime cutoff, int batchSize) {
String sql = """
DELETE FROM access_tokens
WHERE expiry < :cutoff AND revoked = false
""";
return entityManager.createNativeQuery(sql)
.setParameter("cutoff", cutoff)
.setMaxResults(batchSize)
.executeUpdate();
}
public List<AccessToken> findExpiredTokensBatch(LocalDateTime cutoff, int batchSize) {
String sql = """
SELECT * FROM access_tokens
WHERE expiry < :cutoff AND revoked = false
ORDER BY expiry ASC
LIMIT :batchSize
""";
return entityManager.createNativeQuery(sql, AccessToken.class)
.setParameter("cutoff", cutoff)
.setParameter("batchSize", batchSize)
.getResultList();
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The OAuth service will use event-driven architecture for asynchronous operations that don't need to block the main request flow. The key boundaries are:
- Token refresh and revocation: When a refresh token is used or a token is explicitly revoked, we need to invalidate related sessions and notify other services that may be using these tokens.
- User session management: When a user's account is updated (e.g., password change), we need to invalidate all active sessions for that user.
- Audit logging: All token operations (creation, refresh, revocation) should be logged asynchronously for compliance and security monitoring.
- Token expiration: When tokens expire, we may want to clean up related resources or send notifications.
Synchronous operations include:
- Token generation (authorization code, implicit, client credentials)
- Token validation
- User authentication flow
Message schema design with sample payload
We'll use JSON for message payloads with versioning strategy:
{
"version": "1.0",
"event_type": "oauth.token.revoked",
"timestamp": "2023-10-15T14:30:00Z",
"payload": {
"token_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"user_id": "user-123",
"client_id": "client-456",
"token_type": "refresh",
"expires_at": "2023-10-15T15:30:00Z"
},
"source": "oauth-service",
"correlation_id": "correlation-789"
}
Dead letter queue strategy and poison message handling
For failed message processing, we'll implement a dead-letter queue (DLQ) strategy:
- Retry policy: Messages will be retried up to 3 times with exponential backoff (1s, 4s, 9s)
- DLQ handling: Messages that fail after max retries will be sent to a DLQ topic
- Manual intervention: Failed messages in DLQ will trigger alerts and require manual review
At-least-once vs. exactly-once delivery
We'll use at-least-once delivery for the following reasons:
- Token operations are idempotent (reprocessing a token revocation doesn't cause harm)
- Simpler implementation and better performance
- Our system can handle duplicate events gracefully
Retry policy for consumers
public class RetryPolicy {
private static final int MAX_RETRIES = 3;
private static final long INITIAL_DELAY_MS = 1000;
private static final double BACKOFF_MULTIPLIER = 4.0;
public boolean shouldRetry(int attempt, Exception error) {
if (attempt >= MAX_RETRIES) return false;
// Don't retry on certain error types
if (error instanceof IllegalArgumentException ||
error instanceof InvalidTokenException) {
return false;
}
return true;
}
public long getDelay(int attempt) {
return (long) Math.pow(BACKOFF_MULTIPLIER, attempt) * INITIAL_DELAY_MS;
}
}
Outbox pattern for reliable event publishing
We'll implement the outbox pattern to ensure message delivery reliability:
@Entity
@Table(name = "oauth_events")
public class OAuthEvent {
@Id
private UUID id;
@Column(name = "event_type")
private String eventType;
@Column(name = "payload")
private String payload;
@Column(name = "status")
private EventStatus status = EventStatus.PENDING;
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "processed_at")
private LocalDateTime processedAt;
public enum EventStatus {
PENDING, PROCESSED, FAILED
}
}
Sequence diagram for async flows
sequenceDiagram
participant Client
participant OAuthService
participant EventPublisher
participant MessageBroker
participant SessionManager
participant AuditLogger
Client->>OAuthService: Request token refresh
OAuthService->>OAuthService: Validate refresh token
OAuthService->>OAuthService: Generate new access token
OAuthService->>EventPublisher: Publish token.revoked event
EventPublisher->>MessageBroker: Publish to 'oauth.events' topic
MessageBroker->>SessionManager: Deliver to session management service
MessageBroker->>AuditLogger: Deliver to audit logging service
Note over OAuthService,EventPublisher: Async processing - no response delay
The outbox pattern ensures that events are persisted before being published to the message broker, preventing data loss in case of system failures during event publishing. The message broker handles delivery to downstream services with guaranteed delivery semantics.
The async flows are designed to handle burst loads and provide better user experience by not blocking the main authentication flow. The audit logging and session invalidation happen asynchronously to maintain service responsiveness while ensuring compliance and security requirements are met.
10. Error Handling Design
Error Classification Hierarchy
The OAuth service follows a structured error classification system to enable proper handling and monitoring:
Error
├── ValidationException (400)
│ ├── InvalidRequestException
│ ├── InvalidClientException
│ └── InvalidScopeException
├── AuthenticationException (401)
│ ├── InvalidTokenException
│ └── InsufficientScopeException
├── AuthorizationException (403)
│ ├── AccessDeniedException
│ └── PermissionDeniedException
├── ResourceNotFoundException (404)
│ ├── ClientNotFoundException
│ └── UserNotFoundException
├── ConflictException (409)
│ ├── DuplicateClientException
│ └── TokenAlreadyIssuedException
├── RateLimitException (429)
│ └── TooManyRequestsException
├── InternalServerException (500)
│ ├── DatabaseException
│ ├── ExternalServiceException
│ └── UnknownException
└── ServiceUnavailableException (503)
└── MaintenanceModeException
Error Propagation Strategy
- Domain-specific errors: Caught and converted to appropriate OAuth error types before being propagated up the stack
- Infrastructure errors: Bubble up as
InternalServerExceptionorServiceUnavailableExceptionfor transparent logging and alerting - Validation errors: Caught early in the API layer and converted to
InvalidRequestExceptionwith appropriate error codes
Retry Strategy
For external service calls (e.g., database, cache):
public class RetryableOperation {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public <T> T executeWithRetry(Supplier<T> operation) {
int attempts = 0;
while (true) {
try {
return operation.get();
} catch (DatabaseException | ExternalServiceException e) {
attempts++;
if (attempts >= MAX_RETRIES) {
throw new ServiceUnavailableException("Operation failed after " + MAX_RETRIES + " attempts", e);
}
long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempts - 1));
delay += (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new ServiceUnavailableException("Retry interrupted", ie);
}
}
}
}
}
Circuit Breaker Design
public class CircuitBreaker {
private final int failureThreshold;
private final long timeout;
private final int resetTimeout;
private volatile CircuitState state = CircuitState.CLOSED;
private volatile int failureCount = 0;
private volatile long lastFailureTime = 0;
public enum CircuitState {
CLOSED, OPEN, HALF_OPEN
}
public <T> T execute(Supplier<T> operation) {
switch (state) {
case CLOSED:
try {
T result = operation.get();
onSuccess();
return result;
} catch (Exception e) {
onFailure(e);
throw e;
}
case OPEN:
if (System.currentTimeMillis() - lastFailureTime > resetTimeout) {
state = CircuitState.HALF_OPEN;
return operation.get();
}
throw new ServiceUnavailableException("Circuit breaker is OPEN");
case HALF_OPEN:
try {
T result = operation.get();
state = CircuitState.CLOSED;
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
throw e;
}
}
throw new IllegalStateException("Unexpected circuit state");
}
private void onSuccess() {
failureCount = 0;
}
private void onFailure(Exception e) {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= failureThreshold) {
state = CircuitState.OPEN;
}
}
}
Fallback Behavior Design
public class OAuthServiceFallback {
private final Cache<String, Client> clientCache;
private final RedisTemplate<String, String> redisTemplate;
public Client getClientWithFallback(String clientId) {
try {
return clientService.getClient(clientId);
} catch (DatabaseException | ServiceUnavailableException e) {
// Fallback to cache or default client
Client cached = clientCache.getIfPresent(clientId);
if (cached != null) {
return cached;
}
// Fallback to default client or throw error
return createDefaultClient();
}
}
public TokenResponse generateTokenWithFallback(TokenRequest request) {
try {
return tokenService.generateToken(request);
} catch (DatabaseException | ExternalServiceException e) {
// Log error and return fallback response
logger.warn("Token generation failed, returning fallback response", e);
return TokenResponse.builder()
.error("server_error")
.errorDescription("Temporary service unavailability")
.build();
}
}
}
Error Logging Format
public class OAuthErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(OAuthErrorLogger.class);
public void logOAuthError(String errorCode, String errorDescription,
Map<String, Object> context) {
Map<String, Object> logContext = new HashMap<>();
logContext.put("error_code", errorCode);
logContext.put("error_description", errorDescription);
logContext.put("timestamp", Instant.now().toString());
logContext.put("trace_id", MDC.get("trace_id"));
logContext.put("span_id", MDC.get("span_id"));
logContext.put("user_id", context.get("user_id"));
logContext.put("client_id", context.get("client_id"));
logContext.put("request_id", context.get("request_id"));
logContext.put("ip_address", context.get("ip_address"));
logContext.put("error_stack_trace", getStackTrace(context.get("exception")));
logger.error("OAuth Error: {}", logContext);
}
private String getStackTrace(Object exception) {
if (exception instanceof Throwable) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
((Throwable) exception).printStackTrace(pw);
return sw.toString();
}
return "No stack trace available";
}
}
Alerting Thresholds
| Error Type | Alert Threshold | Severity |
|---|---|---|
DatabaseException | > 10 errors/minute for 5 minutes | CRITICAL |
ExternalServiceException | > 5 errors/minute for 3 minutes | MAJOR |
InvalidTokenException | > 100 errors/minute for 1 minute | MINOR |
RateLimitException | > 500 errors/minute for 1 minute | WARNING |
ServiceUnavailableException | > 50 errors/minute for 2 minutes | CRITICAL |
Error Response Format
All error responses follow the OAuth 2.0 specification:
{
"error": "invalid_request",
"error_description": "The request is missing a required parameter, includes an invalid parameter value, or is otherwise malformed.",
"error_uri": "https://docs.example.com/oauth/errors#invalid_request",
"state": "abc123"
}
Error Handling in Service Layer
@Service
public class OAuthTokenService {
private final TokenRepository tokenRepository;
private final ClientRepository clientRepository;
private final OAuthErrorLogger errorLogger;
public TokenResponse generateToken(TokenRequest request) {
try {
// Validate input
validateTokenRequest(request);
// Fetch client
Client client = clientRepository.findByClientId(request.getClientId())
.orElseThrow(() -> new InvalidClientException("Client not found"));
// Validate client credentials
if (!validateClientSecret(client, request.getClientSecret())) {
throw new InvalidClientException("Invalid client credentials");
}
// Validate scope
if (!validateScope(client, request.getScope())) {
throw new InvalidScopeException("Invalid scope requested");
}
// Generate token
Token token = generateAndStoreToken(client, request);
return TokenResponse.builder()
.accessToken(token.getAccessToken())
.tokenType("Bearer")
.expiresIn(token.getExpiresIn())
.scope(token.getScope())
.build();
} catch (InvalidRequestException | InvalidClientException |
InvalidScopeException e) {
errorLogger.logOAuthError(e.getErrorCode(), e.getMessage(),
buildErrorContext(request));
throw e;
} catch (DatabaseException | ExternalServiceException e) {
errorLogger.logOAuthError("server_error", "Internal server error",
buildErrorContext(request));
throw new ServiceUnavailableException("Service temporarily unavailable", e);
}
}
}
Error Budget Considerations
- Error Budget: 2% of total requests (100k requests = 2k errors)
- Budget Consumption: Errors that impact user experience or system stability count toward the budget
- Alerting: When error rate exceeds 1% of total requests, trigger on-call alert
- Grace Period: Allow 5 minutes of error accumulation before triggering alerts
11. Security Design
Input validation and sanitization strategy
All inputs are validated using a combination of:
- Regex-based validation for format constraints (e.g., email, URI patterns)
- Length limits to prevent buffer overflow attacks
- Type checking with strict parsing (e.g.,
UUID.fromString()for IDs) - Custom validation rules using Apache Commons Validator or similar libraries
- SQL injection prevention through parameterized queries and ORM usage
SQL injection prevention
- All database queries are constructed using parameterized/prepared statements
- No dynamic SQL string concatenation
- ORM is used for all data access (e.g., Hibernate/JPA)
- Stored procedures are avoided in favor of parameterized queries
Authentication middleware design
- JWT-based authentication with HS256 signing algorithm (symmetric key)
- Middleware intercepts all protected routes and validates JWT tokens
- Token expiration is enforced via
expclaim (default 1 hour) - Refresh token rotation with short-lived access tokens (1 hour) and long-lived refresh tokens (30 days)
- Token revocation via Redis-based blacklisting (short TTL of 1 hour for revoked tokens)
Authorization enforcement
- Role-Based Access Control (RBAC) is implemented at service layer:
- Roles:
ADMIN,USER,SERVICE - Permissions are mapped to roles via a permission matrix
- Authorization checks performed in service layer before operation execution
- Roles:
- Resource-level permissions are enforced for sensitive operations (e.g., admin actions on users)
- ABAC is considered for complex policies but not implemented due to complexity and current scope
Sensitive data encryption at rest and in transit
- At rest: AES-256 encryption using AWS KMS or similar managed key service
- In transit: TLS 1.3 with HSTS header enforcement (max-age 31536000)
- Secrets storage: AWS Secrets Manager or Vault for storing client secrets and signing keys
- Token encryption: Access tokens are encrypted at rest using AES-256-GCM
PII handling and masking strategy
- PII fields (email, phone) are encrypted at rest using AES-256
- PII in logs is masked (e.g.,
user@example.com→u***@example.com) - Log format includes trace_id and span_id but excludes sensitive fields
- Audit logs capture only necessary PII for compliance (e.g., user login, consent changes)
Secrets management
- Secrets stored in AWS Secrets Manager or HashiCorp Vault
- Rotation policy: Client secrets rotated every 90 days
- Access control via IAM roles or Vault policies to restrict access to secrets
- Environment-specific keys for dev/staging/prod environments
Audit logging design
- Events logged:
- User login/logout
- Token generation/revocation
- Client registration/update
- Admin actions (user deletion, role changes)
- Log format: JSON with fields:
{ "timestamp": "2023-10-15T14:30:00Z", "userId": "uuid", "action": "TOKEN_GENERATED", "ipAddress": "192.168.1.100", "userAgent": "Mozilla/5.0...", "traceId": "abc123", "sessionId": "session-uuid" } - Retention policy: 90 days for audit logs, archived to S3 after 30 days
- Compliance: Logs are retained for audit purposes per SOC2 and GDPR requirements
12. Testing Strategy
Unit Testing Scope and Coverage Targets
Unit testing will cover 85%+ of business logic with 90%+ line coverage for core service classes. Each domain object will have dedicated unit tests covering all state transitions and validation logic. Service layer methods will be tested in isolation with mocked dependencies, focusing on:
- Authentication flow validation (token generation, refresh, revocation)
- Authorization checks and permission validation
- Token lifecycle management (expiry, renewal, invalidation)
- Error condition handling (expired tokens, malformed requests)
- Business rule enforcement (rate limits, scope validation)
Integration Testing Strategy
Integration tests will validate end-to-end flows using embedded databases (H2 for unit tests, HSQLDB for integration) and mock external services:
- Database persistence layer testing with real SQL schema
- OAuth2 flow end-to-end validation (authorization code, implicit, client credentials)
- Token exchange and refresh scenarios
- External provider integration testing (Google, GitHub) with WireMock
- Multi-tenant token validation across different client configurations
Contract Testing
Contract testing will be implemented using Pact for service-to-service contracts between OAuth service and downstream consumers. This ensures that:
- Token validation endpoints maintain consistent response formats
- Authorization header parsing follows agreed specifications
- Error responses match defined schemas across all endpoints
- Client credentials validation maintains contract integrity
Performance Testing Tool and Key Scenarios
Performance testing will use k6 with the following key scenarios:
- Concurrent token generation: 1000 concurrent requests generating access tokens
- Token refresh throughput: 500 concurrent refresh operations per second
- Authorization header validation: 2000 concurrent requests validating existing tokens
- Database connection pool stress test: 500 concurrent database operations
Test Data Management Strategy
Test data will be managed through:
- Factory patterns for generating valid OAuth client configurations
- Fixture files containing pre-defined test users, clients, and scopes
- Seed scripts for initializing test database state with realistic data sets
- Data cleanup procedures to ensure test isolation between runs
- Parameterized test cases for edge case validation (empty scopes, invalid grants, malformed tokens)
The testing strategy prioritizes coverage of authentication flows, token security validation, and concurrent request handling to ensure the OAuth service can handle production-scale usage while maintaining security guarantees.
13. Observability Design
Structured Logging Schema
The logging system will use structured JSON format with consistent fields across all services to enable effective log aggregation and analysis. The schema includes:
{
"timestamp": "2023-10-15T14:30:45.123Z",
"level": "INFO",
"service": "oauth-service",
"trace_id": "1234567890abcdef1234567890abcdef",
"span_id": "abcdef1234567890abcdef1234567890",
"user_id": "user-123",
"entity_id": "client-456",
"request_id": "req-789",
"operation": "token_exchange",
"message": "Token exchange successful",
"duration_ms": 45,
"error_code": "INVALID_GRANT",
"error_message": "Invalid grant type",
"http_method": "POST",
"http_path": "/oauth/token",
"http_status": 400,
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"jwt_token_id": "token-abc123"
}
PII masking rules:
- User IDs and email addresses are masked in logs (e.g.,
user-123→user-***) - JWT tokens are truncated (e.g.,
eyJhbGciOiJIUzI1NiIs...→eyJhbGciOiJIUz...) - IP addresses are masked to network level (e.g.,
192.168.1.1→192.168.0.0)
Distributed Tracing Instrumentation
All key operations will be instrumented with OpenTelemetry to enable end-to-end tracing:
Custom spans to add:
oauth.token_exchange- Token exchange flowoauth.authorization_code_flow- Authorization code grant flowoauth.client_authentication- Client credential validationoauth.token_validation- JWT token verificationoauth.refresh_token_flow- Refresh token exchange
Attributes to capture per key operation:
Span currentSpan = Span.current();
currentSpan.setAttribute("oauth.client_id", clientId);
currentSpan.setAttribute("oauth.grant_type", grantType);
currentSpan.setAttribute("oauth.token_type", tokenType);
currentSpan.setAttribute("oauth.scopes", String.join(",", scopes));
currentSpan.setAttribute("oauth.user_id", userId);
currentSpan.setAttribute("oauth.is_refresh_token", isRefreshToken);
Metrics Design
RED Method Metrics (Rate, Errors, Duration):
| Metric Name | Type | Labels | Description |
|---|---|---|---|
oauth_requests_total | Counter | method, path, status_code | Total request count |
oauth_requests_duration_seconds | Histogram | method, path, status_code | Request processing time |
oauth_requests_errors_total | Counter | method, path, error_type | Request error count |
oauth_token_exchanges_total | Counter | grant_type, client_id | Token exchange count |
oauth_token_validation_failures_total | Counter | validation_type, error_code | Token validation failures |
oauth_active_sessions_gauge | Gauge | client_id | Active session count |
Health Check Endpoint Design
@RestController
@RequestMapping("/health")
public class HealthController {
@GetMapping("/live")
public ResponseEntity<Map<String, Object>> live() {
Map<String, Object> response = new HashMap<>();
response.put("status", "healthy");
response.put("timestamp", Instant.now());
return ResponseEntity.ok(response);
}
@GetMapping("/ready")
public ResponseEntity<Map<String, Object>> ready() {
Map<String, Object> response = new HashMap<>();
response.put("status", "ready");
response.put("timestamp", Instant.now());
response.put("database", databaseHealth());
response.put("cache", cacheHealth());
response.put("external_services", externalServicesHealth());
return ResponseEntity.ok(response);
}
@GetMapping("/startup")
public ResponseEntity<Map<String, Object>> startup() {
Map<String, Object> response = new HashMap<>();
response.put("status", "startup_complete");
response.put("timestamp", Instant.now());
response.put("dependencies", dependenciesStatus());
return ResponseEntity.ok(response);
}
}
Alerting Thresholds
Critical alerts (require on-call escalation):
oauth_requests_errors_total{status_code="5xx"}> 10 errors/minute for 5 minutesoauth_requests_duration_seconds{percentile="99"}> 500ms for 1 minuteoauth_token_validation_failures_total> 50 failures/minute for 2 minutesoauth_active_sessions_gauge> 100k concurrent sessions for 5 minutes
Warning alerts (require investigation):
oauth_requests_errors_total{status_code="4xx"}> 100 errors/minute for 5 minutesoauth_requests_duration_seconds{percentile="95"}> 200ms for 1 minuteoauth_token_exchanges_total{grant_type="authorization_code"}< 50% of total for 10 minutes
On-call escalation path:
- Primary on-call engineer (OAuth service owner)
- Security team (for auth-related alerts)
- Platform engineering (infrastructure issues)
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Token format and compatibility: Should we support JWT-based tokens (for stateless validation) or opaque token format (for enhanced security and revocation capabilities)? This decision affects client-side implementation complexity and integration with existing systems.
- Authorization server vs. resource server separation: Should the OAuth service handle both authorization and resource server responsibilities, or should it be strictly an authorization server that delegates resource validation to separate services?
- Support for multiple grant types: Which OAuth 2.0 grant types should we support (Authorization Code, Implicit, Resource Owner Password Credentials, Client Credentials)? Supporting more grant types increases complexity but improves flexibility for different client types.
Known trade-offs and justifications
- Token size vs. performance: JWT tokens are self-contained but can become large with extensive claims, increasing network overhead and storage requirements. Opaque tokens are compact but require database lookups for validation.
- Stateless vs. stateful token validation: Stateless JWT validation provides better scalability but removes the ability to immediately revoke tokens. Stateful validation allows for immediate revocation but introduces state management complexity.
- Database vs. in-memory caching: Using Redis for token storage provides persistence and distributed access but introduces network latency. In-memory storage offers better performance but lacks persistence across service restarts.
- User session vs. token-based authentication: Session-based approaches provide better control over user sessions but require session storage and management. Token-based approaches are more scalable but less flexible for immediate session invalidation.
Risks and mitigation strategies
- Token replay attacks: Risk of stolen tokens being reused. Mitigation includes short token expiration times, refresh token rotation, and implementing device fingerprinting.
- Cross-site request forgery (CSRF): Risk of malicious sites making unauthorized requests on behalf of authenticated users. Mitigation includes CSRF token generation and validation, secure cookie attributes (HttpOnly, SameSite), and strict origin validation.
- Token storage security: Risk of token exposure in logs or error messages. Mitigation includes comprehensive logging sanitization, secure token handling practices, and proper error response design.
- Rate limiting bypass: Risk of malicious actors bypassing rate limits through multiple IP addresses or proxies. Mitigation includes IP-based rate limiting with adaptive thresholds, request pattern analysis, and integration with threat detection systems.
- Data consistency in distributed environment: Risk of token state inconsistency across microservices. Mitigation includes implementing eventual consistency patterns, distributed transaction management, and proper cache invalidation strategies.
Technical debt acknowledged
- Legacy protocol support: Supporting older OAuth 1.0 or deprecated grant types introduces technical debt and maintenance overhead that may not be immediately necessary but could complicate future upgrades.
- Tight coupling between services: If the OAuth service is tightly coupled to specific client applications or resource servers, future flexibility in system architecture will be reduced.
- Inconsistent error handling: Different error responses across endpoints can lead to inconsistent client-side handling and debugging difficulties.
- Limited extensibility: Hard-coded token formats or limited customization options for claims and scopes may require significant refactoring in the future to support new use cases.
- Monitoring gap: Initial implementation may lack comprehensive observability for token usage patterns, authentication success/failure rates, and user behavior analytics that would be valuable for security and operational insights.