Skip to content

OAuth Token Introspection

Priority: LOW Estimated Time: 8-12 hours Status: Not Planned

← Back to Enhancements


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

http
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_token

Response Format

Active Token:

json
{
  "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:

json
{
  "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:

typescript
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:

typescript
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:

typescript
// 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:

bash
# OAuth Token Introspection
OAUTH_INTROSPECTION_ENABLED=true
OAUTH_INTROSPECTION_REQUIRE_CLIENT_AUTH=true
OAUTH_INTROSPECTION_CACHE_TTL=300  # 5 minutes

Client Usage Examples

Using cURL

bash
# Introspect an access token
curl -X POST https://seed.example.com/oauth/introspect \
  -u "client-id:client-secret" \
  -d "token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

Using Node.js

javascript
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)

yaml
plugins:
  - name: openid-connect
    config:
      introspection_endpoint: https://seed.example.com/oauth/introspect
      client_id: api-gateway
      client_secret: gateway-secret
      cache_ttl: 300

Security Considerations

1. Client Authentication

Always require authentication for introspection endpoint:

typescript
// 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 authentication

2. Rate Limiting

Aggressive rate limiting to prevent abuse:

typescript
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:

typescript
// 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:

typescript
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

typescript
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

typescript
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:

typescript
// 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

  1. Scope Validation: Should introspection check OAuth scopes?
  2. Custom Claims: Allow adding custom claims to introspection response?
  3. Batch Introspection: Support introspecting multiple tokens at once?
  4. Webhook Integration: Notify on token validation failures?

  • Audit Logging - Log introspection requests
  • Enhanced Rate Limiting - Per-client introspection limits
  • Multi-Tenancy - Tenant-scoped introspection

← Back to Enhancements

Released under the MIT License.