Skip to content

Token Revocation

Status: ✅ IMPLEMENTED Priority: 🔴 HIGH Implementation Time: 4 hours Risk Level: MEDIUM Impact: Users can invalidate compromised tokens

← Back to Enhancements


Implementation Summary

Token revocation has been successfully implemented following RFC 7009 specifications:

  • /oauth/revoke endpoint created
  • ✅ Redis-based token lookup with SCAN for performance
  • ✅ Proxies revocation to upstream IdP (if configured)
  • ✅ Always returns HTTP 200 per RFC 7009
  • ✅ Comprehensive test coverage (11 tests passing)
  • ✅ OAuth metadata updated with revocation endpoint
  • ✅ No authentication required (public endpoint per RFC 7009)

Implementation Files:


Problem Statement (Original)

Users have no way to explicitly revoke access tokens or refresh tokens. Once a token is issued, it remains valid until expiration even if:

  • User suspects token has been compromised
  • User wants to forcibly logout from all devices
  • Admin needs to revoke user access immediately
  • Device is lost or stolen

Current Workaround: Wait for token expiration (typically 1 hour for access tokens)

This is a security gap - users cannot take immediate action when security is compromised.


Current Behavior

Issues:

  • No /oauth/revoke endpoint implemented
  • Tokens remain in token store indefinitely (until TTL)
  • No communication with IdP for revocation
  • No session termination on token revocation

Proposed Solution

Implement RFC 7009 OAuth 2.0 Token Revocation endpoint with both local and upstream revocation.


Implementation

1. Token Revocation Endpoint

Create src/routes/oauth-revoke.ts:

typescript
import { Router } from "express";
import { requireAuth } from "../middleware/auth.js";
import { getTokenStore } from "../services/token-store.js";
import { removeTransport, getSessionByToken } from "../mcp/mcp.js";
import { config } from "../config/index.js";
import { logger } from "../services/logger.js";
import { tokenRevocationAttempts } from "../services/metrics.js";

export const revokeRouter = Router();

/**
 * POST /oauth/revoke
 *
 * Revoke an access token or refresh token per RFC 7009
 *
 * Request:
 * {
 *   "token": "eyJhbGc...",
 *   "token_type_hint": "access_token" | "refresh_token" (optional)
 * }
 *
 * Response:
 * HTTP 200 OK (always, per RFC 7009)
 */
revokeRouter.post("/", requireAuth, async (req, res) => {
  const timer = tokenRevocationAttempts.startTimer();

  try {
    const { token, token_type_hint } = req.body;

    if (!token) {
      // Per RFC 7009, return 400 for invalid request
      return res.status(400).json({
        error: "invalid_request",
        error_description: "Missing token parameter",
      });
    }

    // 1. Determine token type
    const isRefreshToken = token_type_hint === "refresh_token" ||
                          token.length > 500; // Heuristic: JWTs are shorter

    // 2. Find associated session(s)
    const sessions = await findSessionsByToken(token, req.user.sub);

    // 3. Revoke locally (remove from token store and terminate sessions)
    for (const sessionId of sessions) {
      try {
        // Remove tokens from Redis
        const tokenStore = getTokenStore();
        await tokenStore.delete(sessionId);

        // Terminate MCP session
        await removeTransport(sessionId);

        logger.info("Token revoked and session terminated", {
          sessionId,
          userSub: req.user.sub,
          tokenType: isRefreshToken ? "refresh_token" : "access_token",
          category: "token-revocation",
        });
      } catch (error) {
        logger.error("Failed to revoke session", {
          sessionId,
          error: error instanceof Error ? error.message : String(error),
          category: "token-revocation",
        });
      }
    }

    // 4. Revoke at upstream IdP (if supported)
    if (config.oauth.revocationUrl) {
      try {
        await revokeAtIdP(token, token_type_hint);

        logger.info("Token revoked at IdP", {
          userSub: req.user.sub,
          tokenType: token_type_hint || "unknown",
          category: "token-revocation",
        });
      } catch (error) {
        logger.error("Failed to revoke at IdP", {
          error: error instanceof Error ? error.message : String(error),
          category: "token-revocation",
        });
        // Continue - local revocation succeeded
      }
    }

    tokenRevocationAttempts.inc({ result: "success" });
    timer({ result: "success" });

    // Per RFC 7009: Always return 200, don't reveal token validity
    res.status(200).json({
      success: true,
    });

  } catch (error) {
    logger.error("Token revocation error", {
      error: error instanceof Error ? error.message : String(error),
      category: "token-revocation",
    });

    tokenRevocationAttempts.inc({ result: "failure" });
    timer({ result: "failure" });

    // Per RFC 7009: Return 200 even on error (don't reveal info)
    res.status(200).json({
      success: true,
    });
  }
});

/**
 * Find all sessions associated with a token
 */
async function findSessionsByToken(
  token: string,
  userSub: string
): Promise<string[]> {
  const tokenStore = getTokenStore();
  const sessions: string[] = [];

  // Search through all user sessions (indexed by userSub in Redis)
  const sessionIds = await tokenStore.getSessionsByUser(userSub);

  for (const sessionId of sessionIds) {
    const stored = await tokenStore.get(sessionId);

    if (stored && (stored.accessToken === token || stored.refreshToken === token)) {
      sessions.push(sessionId);
    }
  }

  return sessions;
}

/**
 * Revoke token at upstream IdP
 */
async function revokeAtIdP(
  token: string,
  tokenTypeHint?: string
): Promise<void> {
  if (!config.oauth.revocationUrl) {
    return; // IdP doesn't support revocation
  }

  const params = new URLSearchParams({
    token,
    client_id: config.oidc.audience || "",
  });

  if (tokenTypeHint) {
    params.append("token_type_hint", tokenTypeHint);
  }

  const response = await fetch(config.oauth.revocationUrl, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: params,
  });

  if (!response.ok) {
    throw new Error(`IdP revocation failed: ${response.status}`);
  }
}

2. Session Lookup by User

Add to src/services/token-store.ts:

typescript
export class RedisTokenStore implements TokenStore {
  // ... existing methods

  /**
   * Get all session IDs for a user (for revocation)
   */
  async getSessionsByUser(userSub: string): Promise<string[]> {
    const pattern = `${this.keyPrefix}*`;
    const keys = await this.redis.keys(pattern);
    const sessions: string[] = [];

    for (const key of keys) {
      const sessionId = key.replace(this.keyPrefix, "");
      const tokens = await this.get(sessionId);

      // Check if session belongs to user (requires storing userSub in tokens)
      if (tokens && tokens.userSub === userSub) {
        sessions.push(sessionId);
      }
    }

    return sessions;
  }
}

Note: This requires adding userSub to StoredTokens interface.

3. Configuration

Add to src/config/oauth.ts:

typescript
export const oauthConfig = {
  // ... existing config
  revocationUrl: process.env.OAUTH_REVOCATION_URL ?? "",
};

4. Metrics

Add to src/services/metrics.ts:

typescript
export const tokenRevocationAttempts = new Counter({
  name: 'token_revocation_attempts_total',
  help: 'Total token revocation attempts',
  labelNames: ['result'], // success, failure
  registers: [register],
});

5. Register Route

Add to src/app.ts:

typescript
import { revokeRouter } from "./routes/oauth-revoke.js";

app.use("/oauth/revoke", revokeRouter);

User Experience

Revoke Specific Session

bash
# User revokes specific access token
curl -X POST https://seed.example.com/oauth/revoke \
  -H "Authorization: Bearer eyJhbGc..." \
  -H "Content-Type: application/json" \
  -d '{
    "token": "eyJhbGc...",
    "token_type_hint": "access_token"
  }'

# Response (always 200)
{
  "success": true
}

Revoke All Sessions (Logout Everywhere)

bash
# User revokes refresh token (terminates all sessions using that token)
curl -X POST https://seed.example.com/oauth/revoke \
  -H "Authorization: Bearer eyJhbGc..." \
  -H "Content-Type: application/json" \
  -d '{
    "token": "refresh_token_value...",
    "token_type_hint": "refresh_token"
  }'

Security Considerations

RFC 7009 Compliance

Always Return 200: Per RFC 7009, the endpoint must return 200 even if:

  • Token is invalid
  • Token is already revoked
  • Token doesn't exist

This prevents attackers from learning about token validity.

Authentication Required

Revocation endpoint requires authentication to prevent:

  • Unauthorized revocation of others' tokens
  • Denial of service attacks
  • Token enumeration

IdP Communication

Best Effort: IdP revocation is attempted but local revocation always succeeds

  • If IdP is unreachable, local revocation still works
  • Token remains invalid on Seed server
  • Users are protected even if IdP revocation fails

Token Lookup Optimization

User-Scoped Search: Only search tokens belonging to authenticated user

  • Prevents user A from revoking user B's tokens
  • Limits search space for performance
  • Requires userSub in stored tokens

Edge Cases

1. Token Already Expired

Behavior: Return 200 (success)

  • Per RFC 7009 specification
  • Don't reveal token state

2. Token Not Found in Store

Possible Reasons:

  • Token was never stored (before token refresh feature)
  • Session already expired and cleaned up
  • Token from different Seed instance

Behavior: Return 200 (success)

  • Attempt IdP revocation if configured
  • Log as info (not error)

3. Multiple Sessions with Same Refresh Token

Scenario: User authenticated on multiple devices with same OAuth flow

Behavior: Revoke all sessions

  • Find all sessions with matching refresh token
  • Terminate all MCP sessions
  • User must re-authenticate on all devices

4. IdP Revocation Fails

Behavior: Continue with local revocation

  • User is protected on Seed server
  • Token may still work at IdP
  • Log error for monitoring

Testing

Unit Tests

typescript
describe('Token Revocation', () => {
  it('should revoke access token and terminate session', async () => {
    // Setup
    const sessionId = 'test-session';
    const accessToken = 'access_token_value';
    await tokenStore.set(sessionId, {
      accessToken,
      refreshToken: 'refresh_token_value',
      expiresAt: Date.now() + 3600000,
      tokenType: 'Bearer',
      userSub: 'user123',
    });

    // Revoke
    const response = await request(app)
      .post('/oauth/revoke')
      .set('Authorization', `Bearer ${accessToken}`)
      .send({
        token: accessToken,
        token_type_hint: 'access_token',
      });

    // Assert
    expect(response.status).toBe(200);
    expect(await tokenStore.get(sessionId)).toBeNull();
    expect(await getTransport(sessionId)).toBeUndefined();
  });

  it('should return 200 for invalid token (per RFC 7009)', async () => {
    const response = await request(app)
      .post('/oauth/revoke')
      .set('Authorization', 'Bearer valid_token')
      .send({
        token: 'invalid_token',
      });

    expect(response.status).toBe(200);
    expect(response.body.success).toBe(true);
  });
});

Acceptance Criteria

  • [ ] /oauth/revoke endpoint implemented per RFC 7009
  • [ ] Always returns HTTP 200 (per spec)
  • [ ] Requires authentication
  • [ ] Removes tokens from Redis token store
  • [ ] Terminates associated MCP sessions
  • [ ] Supports both access and refresh token revocation
  • [ ] Optionally revokes at upstream IdP (if OAUTH_REVOCATION_URL configured)
  • [ ] Metrics tracked for revocation attempts
  • [ ] Comprehensive logging for audit trail
  • [ ] Unit tests with >90% coverage
  • [ ] Integration tests with real Redis
  • [ ] Documentation updated

Configuration

Environment Variables

bash
# Optional: IdP token revocation endpoint
OAUTH_REVOCATION_URL=https://auth.example.com/application/o/revoke/

If not set, only local revocation is performed.


Metrics

prometheus
# Total revocation attempts
token_revocation_attempts_total{result="success"}
token_revocation_attempts_total{result="failure"}

# Revocations per user (if userSub label added)
token_revocation_attempts_total{result="success",user_sub="user123"}


Implementation Phases

Phase 1: Local Revocation (2-3 hours)

  • Implement /oauth/revoke endpoint
  • Token store lookup and deletion
  • Session termination
  • Basic metrics and logging

Phase 2: IdP Integration (1-2 hours)

  • Add OAUTH_REVOCATION_URL configuration
  • Implement IdP revocation calls
  • Error handling and fallback

Phase 3: User-Scoped Revocation (1 hour)

  • Add userSub to StoredTokens
  • Implement getSessionsByUser()
  • Security testing

References

Released under the MIT License.