Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-refresh-token-replay-revocation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@roomote/web': patch
---

Revoke the entire remote MCP OAuth refresh-token family when an already-rotated refresh token is replayed, per the OAuth 2.0 Security BCP. Previously the replay was rejected but the rest of the token family stayed valid, so a stolen-token signal never disabled the remaining tokens.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion apps/web/src/app/api/mcp-remote-oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getRemoteMcpOAuthClient,
getRemoteMcpRefreshSession,
promoteRemoteMcpOAuthClient,
revokeRemoteMcpRefreshSessionOnReplay,
rotateRemoteMcpRefreshToken,
verifyPkceChallenge,
} from '@/lib/server/mcp-remote-oauth';
Expand Down Expand Up @@ -80,8 +81,15 @@ export async function POST(request: NextRequest) {
getRemoteMcpRefreshSession(input.refresh_token),
getRemoteMcpOAuthClient(input.client_id),
]);
if (!session) {
// The presented token is not the family's current token. If it is an
// already-rotated token, its replay revokes the entire session family
// per the OAuth 2.0 Security BCP (refresh token rotation with reuse
// detection); unknown or expired tokens are ignored.
await revokeRemoteMcpRefreshSessionOnReplay(input.refresh_token);
return oauthError('invalid_grant');
}
if (
!session ||
!client ||
!client.grantTypes.includes('refresh_token') ||
session.clientId !== input.client_id ||
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/lib/server/mcp-remote-oauth.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions apps/web/src/lib/server/mcp-remote-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ redis.call('SET', KEYS[3], ARGV[4], 'EX', ARGV[6])
return {'ok'}
`;

const REVOKE_SESSION_ON_REPLAY_LUA = `
local marker = redis.call('GET', KEYS[1])
if marker ~= ARGV[1] then
return 0
end
local session = redis.call('GET', KEYS[2])
if session then
local decoded = cjson.decode(session)
redis.call('DEL', ARGV[2] .. decoded.currentTokenHash)
end
redis.call('DEL', KEYS[2])
return 1
`;

const REVOKE_REFRESH_SESSION_LUA = `
local marker = redis.call('GET', KEYS[1])
if marker ~= ARGV[3] then
Expand Down Expand Up @@ -475,6 +489,31 @@ export async function revokeRemoteMcpRefreshSession(
);
}

/**
* Replay of an already-rotated refresh token revokes the whole session family
* (OAuth 2.0 Security BCP): the session and its current token are deleted, so
* every descendant token dies with them. Only tokens carrying the `rotated:`
* marker left behind by a successful rotation trigger this; unknown or expired
* tokens are ignored so random garbage cannot kill a live family. Returns true
* when a replay was detected and the family was revoked.
*/
export async function revokeRemoteMcpRefreshSessionOnReplay(
refreshToken: string,
): Promise<boolean> {
const sessionId = parseRefreshToken(refreshToken);
if (!sessionId) return false;
const tokenHash = refreshTokenHash(refreshToken);
const revoked = await getRedis().eval(
REVOKE_SESSION_ON_REPLAY_LUA,
2,
refreshTokenKey(tokenHash),
refreshSessionKey(sessionId),
`rotated:${sessionId}`,
REFRESH_TOKEN_KEY_PREFIX,
);
return revoked === 1;
}

export async function isRemoteMcpRegistrationAllowed(
registrationFingerprint: string,
): Promise<boolean> {
Expand Down
Loading