OAuth Token Introspection
Priority: LOW Estimated Time: 8-12 hours Status: Not Planned
Overview
Implement RFC 7662 OAuth 2.0 Token Introspection endpoint to allow external services to validate access tokens issued by Seed's OAuth flow.
Use Case
Enable external services to validate tokens without directly accessing the OIDC provider:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ API Gateway │─validate→│ Seed Server │─verify─→│ OIDC │
│ │←─result──│ │←─claims─│ Provider │
└──────────────┘ └──────────────┘ └──────────────┘Benefits:
- Centralized token validation logic
- Reduces direct OIDC provider load
- Can add custom claims/logic
- Useful for microservices architecture
RFC 7662 Specification
Token introspection allows authorized clients to query token metadata.
Request Format
POST /oauth/introspect HTTP/1.1
Host: seed.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic {client_credentials}
token={access_token}
&token_type_hint=access_tokenResponse Format
Active Token:
{
"active": true,
"sub": "user|auth0|12345",
"client_id": "client-abc-123",
"scope": "read write",
"exp": 1735990800,
"iat": 1735987200,
"iss": "https://auth.example.com",
"aud": "seed-mcp-server"
}Inactive/Invalid Token:
{
"active": false
}Implementation
Note: Redis is included in the local development environment when using ./scripts/local (part of the Docker stack).
1. Introspection Endpoint
Create src/routes/oauth/introspect.ts:
import { Router, Request, Response } from 'express';
import { verifyJwt } from '../../services/jwks.js';
import { authenticateClient } from '../../middleware/client-auth.js';
const router = Router();
router.post('/introspect',
authenticateClient, // Require client authentication
async (req: Request, res: Response) => {
try {
const { token, token_type_hint } = req.body;
if (!token) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing token parameter'
});
}
// Validate token
const introspectionResult = await introspectToken(token);
res.json(introspectionResult);
} catch (error) {
logger.error('Token introspection failed:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Failed to introspect token'
});
}
}
);
async function introspectToken(token: string): Promise<IntrospectionResponse> {
try {
// Verify JWT signature and decode
const decoded = await verifyJwt(token);
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < now) {
return { active: false };
}
// Check if token has been revoked (optional)
const isRevoked = await checkRevocation(decoded.jti);
if (isRevoked) {
return { active: false };
}
// Return active token info
return {
active: true,
sub: decoded.sub,
client_id: decoded.azp || decoded.aud,
scope: decoded.scope,
exp: decoded.exp,
iat: decoded.iat,
iss: decoded.iss,
aud: decoded.aud,
username: decoded.email || decoded.preferred_username,
token_type: 'Bearer'
};
} catch (error) {
// Invalid signature, malformed token, etc.
return { active: false };
}
}
interface IntrospectionResponse {
active: boolean;
sub?: string;
client_id?: string;
scope?: string;
exp?: number;
iat?: number;
iss?: string;
aud?: string | string[];
username?: string;
token_type?: string;
}
export default router;2. Client Authentication
Create src/middleware/client-auth.ts:
import { Request, Response, NextFunction } from 'express';
import { clientStore } from '../services/client-store.js';
export async function authenticateClient(
req: Request,
res: Response,
next: NextFunction
) {
try {
// Extract credentials from Authorization header
const authHeader = req.get('authorization');
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).json({
error: 'invalid_client',
error_description: 'Client authentication required'
});
}
// Decode Basic Auth credentials
const credentials = Buffer.from(authHeader.slice(6), 'base64').toString();
const [clientId, clientSecret] = credentials.split(':');
if (!clientId || !clientSecret) {
return res.status(401).json({
error: 'invalid_client',
error_description: 'Invalid client credentials'
});
}
// Verify client exists and secret matches
const client = await clientStore.get(clientId);
if (!client) {
return res.status(401).json({
error: 'invalid_client',
error_description: 'Client not found'
});
}
// Verify client secret (should use constant-time comparison)
const secretMatch = await crypto.subtle.timingSafeEqual(
Buffer.from(clientSecret),
Buffer.from(client.client_secret)
);
if (!secretMatch) {
return res.status(401).json({
error: 'invalid_client',
error_description: 'Invalid client credentials'
});
}
// Attach client to request
req.client = client;
next();
} catch (error) {
logger.error('Client authentication failed:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Authentication failed'
});
}
}3. Token Revocation Support (Optional)
Implement token revocation list in Redis:
// Store revoked tokens
export async function revokeToken(jti: string, exp: number): Promise<void> {
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`revoked:${jti}`, ttl, '1');
}
}
// Check if token is revoked
export async function checkRevocation(jti?: string): Promise<boolean> {
if (!jti) return false;
const revoked = await redis.exists(`revoked:${jti}`);
return revoked === 1;
}Configuration
Add to .env.example:
# OAuth Token Introspection
OAUTH_INTROSPECTION_ENABLED=true
OAUTH_INTROSPECTION_REQUIRE_CLIENT_AUTH=true
OAUTH_INTROSPECTION_CACHE_TTL=300 # 5 minutesClient Usage Examples
Using cURL
# Introspect an access token
curl -X POST https://seed.example.com/oauth/introspect \
-u "client-id:client-secret" \
-d "token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."Using Node.js
const introspect = async (token) => {
const response = await fetch('https://seed.example.com/oauth/introspect', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from('client-id:client-secret').toString('base64')}`
},
body: `token=${token}`
});
return response.json();
};
const result = await introspect(accessToken);
if (result.active) {
console.log('Valid token for user:', result.sub);
} else {
console.log('Invalid or expired token');
}API Gateway Integration (Kong)
plugins:
- name: openid-connect
config:
introspection_endpoint: https://seed.example.com/oauth/introspect
client_id: api-gateway
client_secret: gateway-secret
cache_ttl: 300Security Considerations
1. Client Authentication
Always require authentication for introspection endpoint:
// Option 1: HTTP Basic Auth (RFC 7617)
Authorization: Basic base64(client_id:client_secret)
// Option 2: POST body credentials
client_id=client-id&client_secret=client-secret
// Option 3: mTLS (RFC 8705)
// Use client certificate for authentication2. Rate Limiting
Aggressive rate limiting to prevent abuse:
const introspectionRateLimit = createDistributedRateLimit({
windowMs: 60 * 1000,
max: 100, // 100 introspection requests per minute per client
keyGenerator: (req) => req.client.client_id
});
router.post('/introspect', introspectionRateLimit, authenticateClient, ...);3. Privacy Considerations
Don't leak sensitive information in error responses:
// Bad - reveals if token exists
if (tokenExpired) {
return { active: false, reason: 'expired' };
}
// Good - consistent response
if (tokenInvalid || tokenExpired || tokenRevoked) {
return { active: false };
}Caching Strategy
Cache introspection results to reduce load:
class IntrospectionCache {
private cache = new Map<string, CachedResult>();
async get(token: string): Promise<IntrospectionResponse | null> {
const key = hashToken(token);
const cached = this.cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
return null;
}
async set(token: string, result: IntrospectionResponse, ttl: number): Promise<void> {
const key = hashToken(token);
this.cache.set(key, {
result,
expiresAt: Date.now() + ttl * 1000
});
}
}
// Hash token for privacy
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}Monitoring
Prometheus Metrics
export const introspectionRequests = new promClient.Counter({
name: 'oauth_introspection_requests_total',
help: 'Total OAuth introspection requests',
labelNames: ['client_id', 'result'] // active | inactive
});
export const introspectionDuration = new promClient.Histogram({
name: 'oauth_introspection_duration_seconds',
help: 'OAuth introspection request duration',
labelNames: ['client_id'],
buckets: [0.01, 0.05, 0.1, 0.5, 1]
});
export const introspectionCacheHits = new promClient.Counter({
name: 'oauth_introspection_cache_hits_total',
help: 'OAuth introspection cache hits',
labelNames: ['result'] // hit | miss
});Testing
Unit Tests
describe('Token Introspection', () => {
it('should return active=true for valid token', async () => {
const token = await generateValidToken();
const response = await request(app)
.post('/oauth/introspect')
.auth('client-id', 'client-secret')
.send({ token });
expect(response.body.active).toBe(true);
expect(response.body.sub).toBe('user-123');
});
it('should return active=false for expired token', async () => {
const token = await generateExpiredToken();
const response = await request(app)
.post('/oauth/introspect')
.auth('client-id', 'client-secret')
.send({ token });
expect(response.body.active).toBe(false);
});
it('should require client authentication', async () => {
const response = await request(app)
.post('/oauth/introspect')
.send({ token: 'some-token' });
expect(response.status).toBe(401);
});
});Alternative: Userinfo Endpoint
Instead of introspection, use OIDC's standard userinfo endpoint:
// GET /userinfo
// Authorization: Bearer {access_token}
router.get('/userinfo',
authenticateBearer, // Validate JWT from Authorization header
async (req: Request, res: Response) => {
const user = req.userContext;
res.json({
sub: user.sub,
email: user.email,
name: user.name,
groups: user.groups
});
}
);Difference:
- Introspection: Validates any token (requires client auth)
- Userinfo: Returns info about token in Authorization header
Open Questions
- Scope Validation: Should introspection check OAuth scopes?
- Custom Claims: Allow adding custom claims to introspection response?
- Batch Introspection: Support introspecting multiple tokens at once?
- Webhook Integration: Notify on token validation failures?
Related Enhancements
- Audit Logging - Log introspection requests
- Enhanced Rate Limiting - Per-client introspection limits
- Multi-Tenancy - Tenant-scoped introspection