Skip to content
Merged
23 changes: 19 additions & 4 deletions __tests__/oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2335,18 +2335,33 @@ describe('OAuthProvider', () => {
);

const revokeResponse = await oauthProvider.fetch(revokeRequest, mockEnv, mockCtx);
expect(revokeResponse.status).toBe(200); // Should NOT be unsupported_grant_type anymore

// Verify response doesn't contain unsupported_grant_type error
const revokeResponseText = await revokeResponse.text();
expect(revokeResponseText).not.toContain('unsupported_grant_type');

// Step 3: Verify the token is actually revoked (proves revokeGrant was called)
// Step 3: Verify the access token is actually revoked
const apiRequest = createMockRequest('https://example.com/api/test', 'GET', {
Authorization: `Bearer ${tokens.access_token}`,
});
const apiResponse = await oauthProvider.fetch(apiRequest, mockEnv, mockCtx);
expect(apiResponse.status).toBe(401); // Token should no longer work
expect(apiResponse.status).toBe(401); // Access token should no longer work

// Step 4: Verify refresh token still works
const refreshRequest = createMockRequest(
'https://example.com/oauth/token',
'POST',
{
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
},
`grant_type=refresh_token&refresh_token=${tokens.refresh_token}`
);

const refreshResponse = await oauthProvider.fetch(refreshRequest, mockEnv, mockCtx);
expect(refreshResponse.status).toBe(200); // Refresh token should still work
const newTokens = await refreshResponse.json<any>();
expect(newTokens.access_token).toBeDefined();
expect(newTokens.refresh_token).toBeDefined();
});
});
});
109 changes: 43 additions & 66 deletions src/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,8 +1035,7 @@ class OAuthProviderImpl {

// Determine if this is a revocation request
// RFC 7009: Revocation requests have 'token' parameter but no 'grant_type'
// Also handle case where token_type_hint is provided but no token (should be invalid_request)
const isRevocationRequest = !body.grant_type && (!!body.token || !!body.token_type_hint);
const isRevocationRequest = !body.grant_type && !!body.token;

return {
body,
Expand Down Expand Up @@ -1673,61 +1672,47 @@ class OAuthProviderImpl {
}

/**
* Processes token revocation logic
* @param body - The parsed request body containing token and optional token_type_hint
* - Access tokens: Revokes only the specific token
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
* @param body - The parsed request body containing token parameter
* @param env - Cloudflare Worker environment variables
* @returns Response confirming revocation or error
*/
private async revokeToken(body: any, env: any): Promise<Response> {
const token = body.token;
const tokenTypeHint = body.token_type_hint;

if (!token) {
return this.createErrorResponse('invalid_request', 'Token parameter is required');
}

// Validate token_type_hint before processing (RFC 7009)
if (tokenTypeHint && tokenTypeHint !== 'access_token' && tokenTypeHint !== 'refresh_token') {
return this.createErrorResponse('unsupported_token_type', 'Unsupported token type hint');
const tokenParts = token.split(':');
if (tokenParts.length !== 3) {
return new Response('', { status: 200 });
}

try {
// Parse the token to extract user ID and grant ID
const tokenParts = token.split(':');
if (tokenParts.length !== 3) {
// Invalid token format, but RFC 7009 requires 200 response even for invalid tokens
return new Response('', { status: 200 });
}

const [userId, grantId, _] = tokenParts;

// Determine what type of token this is by checking if it's an access token or refresh token
let isValidToken = false;

// If hint is provided, try that type first for efficiency
if (tokenTypeHint === 'access_token') {
isValidToken = await this.validateAccessToken(token, userId, grantId, env);
} else if (tokenTypeHint === 'refresh_token') {
isValidToken = await this.validateRefreshToken(token, userId, grantId, env);
}

// If hint didn't work or no hint provided, try both types
if (!isValidToken) {
isValidToken =
(await this.validateAccessToken(token, userId, grantId, env)) ||
(await this.validateRefreshToken(token, userId, grantId, env));
}
const [userId, grantId, _] = tokenParts;

// If we found a valid token, revoke the entire grant
if (isValidToken) {
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
}
const isAccessToken = await this.validateAccessToken(token, userId, grantId, env);
const isRefreshToken = await this.validateRefreshToken(token, userId, grantId, env);

return new Response('', { status: 200 });
} catch (error) {
console.error('Token revocation error:', error);
return new Response('', { status: 200 });
if (isAccessToken) {
await this.revokeSpecificAccessToken(token, userId, grantId, env);
} else if (isRefreshToken) {
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
}
return new Response('', { status: 500 });
}

/**
* Revokes a specific access token without affecting the refresh token
* @param token - The access token to revoke
* @param userId - The user ID extracted from the token
* @param grantId - The grant ID extracted from the token
* @param env - Cloudflare Worker environment variables
*/
private async revokeSpecificAccessToken(token: string, userId: string, grantId: string, env: any): Promise<void> {
const accessTokenId = await generateTokenId(token);
const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
await env.OAUTH_KV.delete(tokenKey);
}

/**
Expand All @@ -1739,21 +1724,17 @@ class OAuthProviderImpl {
* @returns Promise<boolean> indicating if the token is valid
*/
private async validateAccessToken(token: string, userId: string, grantId: string, env: any): Promise<boolean> {
try {
const accessTokenId = await generateTokenId(token);
const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
const tokenData = await env.OAUTH_KV.get(tokenKey, { type: 'json' });

if (!tokenData) {
return false;
}
const accessTokenId = await generateTokenId(token);
const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
const tokenData = await env.OAUTH_KV.get(tokenKey, { type: 'json' });

// Check if token is expired
const now = Math.floor(Date.now() / 1000);
return tokenData.expiresAt >= now;
} catch {
if (!tokenData) {
return false;
}

// Check if token is expired
const now = Math.floor(Date.now() / 1000);
return tokenData.expiresAt >= now;
}

/**
Expand All @@ -1765,20 +1746,16 @@ class OAuthProviderImpl {
* @returns Promise<boolean> indicating if the token is valid
*/
private async validateRefreshToken(token: string, userId: string, grantId: string, env: any): Promise<boolean> {
try {
const refreshTokenId = await generateTokenId(token);
const grantKey = `grant:${userId}:${grantId}`;
const grantData = await env.OAUTH_KV.get(grantKey, { type: 'json' });

if (!grantData) {
return false;
}
const refreshTokenId = await generateTokenId(token);
const grantKey = `grant:${userId}:${grantId}`;
const grantData = await env.OAUTH_KV.get(grantKey, { type: 'json' });

// Check if this matches the current or previous refresh token
return grantData.refreshTokenId === refreshTokenId || grantData.previousRefreshTokenId === refreshTokenId;
} catch {
if (!grantData) {
return false;
}

// Check if this matches the current or previous refresh token
return grantData.refreshTokenId === refreshTokenId || grantData.previousRefreshTokenId === refreshTokenId;
}

/**
Expand Down