Token Revocation
Status: ✅ IMPLEMENTED Priority: 🔴 HIGH Implementation Time: 4 hours Risk Level: MEDIUM Impact: Users can invalidate compromised tokens
Implementation Summary
Token revocation has been successfully implemented following RFC 7009 specifications:
- ✅
/oauth/revokeendpoint 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:
- src/routes/oauth-revoke.ts - Revocation endpoint
- src/services/token-store.ts - Token lookup and revocation
- src/routes/oauth-authorization-server.ts - Metadata updated
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/revokeendpoint 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:
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:
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:
export const oauthConfig = {
// ... existing config
revocationUrl: process.env.OAUTH_REVOCATION_URL ?? "",
};4. Metrics
Add to src/services/metrics.ts:
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:
import { revokeRouter } from "./routes/oauth-revoke.js";
app.use("/oauth/revoke", revokeRouter);User Experience
Revoke Specific Session
# 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)
# 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
userSubin 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
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/revokeendpoint 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_URLconfigured) - [ ] 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
# Optional: IdP token revocation endpoint
OAUTH_REVOCATION_URL=https://auth.example.com/application/o/revoke/If not set, only local revocation is performed.
Metrics
# 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"}Related Enhancements
- Automatic Token Refresh - Token lifecycle management
- Session Management API (future) - List and manage sessions
- OAuth Token Introspection - Validate token status
Implementation Phases
Phase 1: Local Revocation (2-3 hours)
- Implement
/oauth/revokeendpoint - Token store lookup and deletion
- Session termination
- Basic metrics and logging
Phase 2: IdP Integration (1-2 hours)
- Add
OAUTH_REVOCATION_URLconfiguration - Implement IdP revocation calls
- Error handling and fallback
Phase 3: User-Scoped Revocation (1 hour)
- Add
userSubtoStoredTokens - Implement
getSessionsByUser() - Security testing