All files / src/oauth/endpoints callback.ts

100% Statements 65/65
89.28% Branches 25/28
100% Functions 1/1
100% Lines 65/65

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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201                                  69x 69x 69x 69x 69x                             69x 19x 19x 1x       1x     18x     18x 3x   3x 2x 2x 1x 1x 1x 1x 1x   1x 1x   1x 1x     2x       2x       15x 1x       1x     14x 1x       1x       13x 13x 1x       1x       12x 1x 1x       1x     11x   11x     8x     7x 7x     7x     7x                       7x                                 7x   7x                   7x 7x 7x 6x     7x         7x   4x     4x     4x 4x 4x       4x 4x     4x      
/**
 * OAuth Callback Endpoint
 *
 * Handles the callback from GitLab after user authorization in Authorization Code Flow.
 * This endpoint receives the GitLab authorization code, exchanges it for tokens,
 * creates a session, and redirects back to the client with an MCP authorization code.
 *
 * Flow:
 * 1. User completes GitLab authorization
 * 2. GitLab redirects to /oauth/callback with code and state
 * 3. We exchange GitLab code for GitLab tokens
 * 4. We create a session with GitLab tokens
 * 5. We generate an MCP authorization code
 * 6. We redirect to client's redirect_uri with MCP code
 */
 
import { Request, Response } from "express";
import { loadOAuthConfig } from "../config";
import { sessionStore } from "../session-store";
import { exchangeGitLabAuthCode, getGitLabUser } from "../gitlab-device-flow";
import { generateSessionId, generateAuthorizationCode, calculateTokenExpiry } from "../token-utils";
import { logger } from "../../logger";
 
/**
 * OAuth callback handler
 *
 * Handles GET /oauth/callback from GitLab after user authorization.
 *
 * Query parameters (from GitLab):
 * - code: GitLab authorization code
 * - state: Internal state we sent to GitLab (maps to AuthCodeFlowState)
 *
 * On success, redirects to client's redirect_uri with:
 * - code: MCP authorization code (for /token exchange)
 * - state: Original client state (for CSRF verification)
 */
export async function callbackHandler(req: Request, res: Response): Promise<void> {
  const config = loadOAuthConfig();
  if (!config) {
    res.status(500).json({
      error: "server_error",
      error_description: "OAuth not configured",
    });
    return;
  }
 
  const { code, state, error, error_description } = req.query as Record<string, string | undefined>;
 
  // Handle GitLab error responses
  if (error) {
    logger.warn({ error, error_description }, "GitLab authorization error");
    // Redirect to client with error if we can find the flow state
    if (state) {
      const flow = sessionStore.getAuthCodeFlow(state);
      if (flow) {
        sessionStore.deleteAuthCodeFlow(state);
        const redirectUrl = new URL(flow.clientRedirectUri);
        redirectUrl.searchParams.set("error", error);
        Eif (error_description) {
          redirectUrl.searchParams.set("error_description", error_description);
        }
        Eif (flow.clientState) {
          redirectUrl.searchParams.set("state", flow.clientState);
        }
        res.redirect(redirectUrl.toString());
        return;
      }
    }
    res.status(400).json({
      error: error,
      error_description: error_description ?? "GitLab authorization failed",
    });
    return;
  }
 
  // Validate required parameters
  if (!code) {
    res.status(400).json({
      error: "invalid_request",
      error_description: "Missing authorization code from GitLab",
    });
    return;
  }
 
  if (!state) {
    res.status(400).json({
      error: "invalid_request",
      error_description: "Missing state parameter",
    });
    return;
  }
 
  // Look up the auth code flow state
  const flow = sessionStore.getAuthCodeFlow(state);
  if (!flow) {
    res.status(400).json({
      error: "invalid_request",
      error_description: "Invalid or expired state. Please start authorization again.",
    });
    return;
  }
 
  // Check if flow has expired
  if (Date.now() > flow.expiresAt) {
    sessionStore.deleteAuthCodeFlow(state);
    res.status(400).json({
      error: "invalid_request",
      error_description: "Authorization flow expired. Please start again.",
    });
    return;
  }
 
  try {
    // Exchange GitLab authorization code for tokens
    const gitlabTokens = await exchangeGitLabAuthCode(code, flow.callbackUri, config);
 
    // Get GitLab user info
    const userInfo = await getGitLabUser(gitlabTokens.access_token);
 
    // Create session
    const sessionId = generateSessionId();
    const now = Date.now();
 
    // Generate MCP authorization code for the client
    const mcpAuthCode = generateAuthorizationCode();
 
    // Store MCP authorization code (single-use, expires in 10 minutes)
    sessionStore.storeAuthCode({
      code: mcpAuthCode,
      sessionId,
      clientId: flow.clientId,
      codeChallenge: flow.codeChallenge,
      codeChallengeMethod: flow.codeChallengeMethod,
      redirectUri: flow.clientRedirectUri,
      expiresAt: now + 10 * 60 * 1000, // 10 minutes
    });
 
    // Create session with GitLab tokens
    // MCP tokens will be set when the authorization code is exchanged via /token
    sessionStore.createSession({
      id: sessionId,
      mcpAccessToken: "", // Set on /token
      mcpRefreshToken: "", // Set on /token
      mcpTokenExpiry: 0, // Set on /token
      gitlabAccessToken: gitlabTokens.access_token,
      gitlabRefreshToken: gitlabTokens.refresh_token,
      gitlabTokenExpiry: calculateTokenExpiry(gitlabTokens.expires_in),
      gitlabUserId: userInfo.id,
      gitlabUsername: userInfo.username,
      clientId: flow.clientId,
      scopes: ["mcp:tools", "mcp:resources"],
      createdAt: now,
      updatedAt: now,
    });
 
    // Clean up the auth code flow state
    sessionStore.deleteAuthCodeFlow(state);
 
    logger.info(
      {
        sessionId: sessionId.substring(0, 8) + "...",
        userId: userInfo.id,
        username: userInfo.username,
      },
      "Authorization Code Flow completed successfully"
    );
 
    // Redirect to client with MCP authorization code
    const redirectUrl = new URL(flow.clientRedirectUri);
    redirectUrl.searchParams.set("code", mcpAuthCode);
    if (flow.clientState) {
      redirectUrl.searchParams.set("state", flow.clientState);
    }
 
    logger.debug(
      { redirectUri: flow.clientRedirectUri },
      "Redirecting to client with authorization code"
    );
 
    res.redirect(redirectUrl.toString());
  } catch (error: unknown) {
    logger.error({ err: error as Error }, "Failed to complete authorization code flow");
 
    // Clean up the flow state on error
    sessionStore.deleteAuthCodeFlow(state);
 
    // Try to redirect to client with error
    const redirectUrl = new URL(flow.clientRedirectUri);
    redirectUrl.searchParams.set("error", "server_error");
    redirectUrl.searchParams.set(
      "error_description",
      error instanceof Error ? error.message : "Failed to complete authorization"
    );
    Eif (flow.clientState) {
      redirectUrl.searchParams.set("state", flow.clientState);
    }
 
    res.redirect(redirectUrl.toString());
  }
}