All files / src/oauth/endpoints revoke.ts

100% Statements 49/49
100% Branches 18/18
100% Functions 2/2
100% Lines 48/48

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105                220x 220x 220x 220x   220x 220x     13x 13x                 220x 26x 26x 1x 1x     25x           25x 2x 2x   23x 23x 1x 1x         22x     22x 22x 2x 2x 2x   20x 20x 1x 1x       19x       1x   1x 1x       18x 1x 1x       17x 1x 1x           16x 16x   4x 4x 4x     12x 12x    
/**
 * OAuth Token Revocation Endpoint (RFC 7009)
 *
 * Disconnects an account: revoking either the MCP access token or the refresh token ends
 * the whole session on every replica, and the linked GitLab grant is revoked as well.
 */
 
import { Request, Response } from 'express';
import { loadOAuthConfig } from '../config';
import { sessionStore } from '../session-store';
import { endSession } from '../grant-revocation';
import { logInfo, logError, truncateId } from '../../logger';
import type { OAuthErrorResponse } from '../types';
import { singleValuedParams } from '../request-params';
import { authenticateClient } from '../client-auth';
 
function sendError(res: Response, status: number, error: string, description: string): void {
  const response: OAuthErrorResponse = { error, error_description: description };
  res.status(status).json(response);
}
 
/**
 * Revocation endpoint handler
 *
 * Handles POST /revoke with `token`, optional `token_type_hint` and the client's
 * credentials (`client_id` of a public client, or a confidential client's secret).
 */
export async function revokeHandler(req: Request, res: Response): Promise<void> {
  const config = loadOAuthConfig();
  if (!config) {
    sendError(res, 500, 'server_error', 'OAuth not configured');
    return;
  }
 
  const params = singleValuedParams(req.body, [
    'token',
    'token_type_hint',
    'client_id',
    'client_secret',
  ]);
  if (typeof params === 'string') {
    sendError(res, 400, 'invalid_request', `${params} must not be repeated`);
    return;
  }
  const { token } = params;
  if (!token) {
    sendError(res, 400, 'invalid_request', 'Missing token');
    return;
  }
 
  let session;
  let client_id: string | undefined;
  try {
    // RFC 7009 section 2.1: the client authenticates as at the token endpoint; a public
    // client identifies itself with client_id.
    const client = await authenticateClient(req, params);
    if (!client.authenticated) {
      if (client.challenge) res.set('WWW-Authenticate', 'Basic realm="gitlab-mcp"');
      sendError(res, client.status, client.error, client.description);
      return;
    }
    client_id = client.clientId;
    if (!client_id) {
      sendError(res, 400, 'invalid_request', 'Missing client_id');
      return;
    }
 
    // token_type_hint only orders the lookup (RFC 7009 2.1); both kinds are checked.
    session =
      (await sessionStore.getSessionByRefreshToken(token)) ??
      (await sessionStore.getSessionByToken(token));
  } catch (error: unknown) {
    logError('Token revocation lookup failed', { err: error as Error });
    // RFC 7009 section 2.2.1: the client retries after a 503.
    sendError(res, 503, 'temporarily_unavailable', 'Revocation is temporarily unavailable');
    return;
  }
 
  // RFC 7009 section 2.2: an unknown or already revoked token is answered with 200.
  if (!session) {
    res.status(200).end();
    return;
  }
 
  // RFC 7009 section 2.1: only the client the token was issued to may revoke it.
  if (session.clientId !== client_id) {
    sendError(res, 400, 'invalid_request', 'Token was issued to another client');
    return;
  }
 
  // Revoking a refresh token revokes the access tokens of the same grant (RFC 7009
  // section 2.1), so the session goes away with both, and with the accounts linked to it;
  // every one of their GitLab grants is ended too.
  try {
    await endSession(session, config);
  } catch (error: unknown) {
    logError('Token revocation failed', { err: error as Error });
    sendError(res, 503, 'temporarily_unavailable', 'Revocation is temporarily unavailable');
    return;
  }
 
  logInfo('OAuth session revoked', { sessionId: truncateId(session.id) });
  res.status(200).end();
}