All files / src/cli/commands/setup-app server.ts

0% Statements 0/40
0% Branches 0/17
0% Functions 0/6
0% Lines 0/40

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                                                                                                                                                                                                                                                                                                                                                         
/**
 * Local HTTP server for GitHub App manifest flow.
 * Serves a form that POSTs the manifest to GitHub, then receives the callback.
 */
 
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
import { URL } from 'node:url';
import type { GitHubAppManifest } from './manifest.js';
 
export interface CallbackResult {
  code: string;
}
 
export interface ServerOptions {
  port: number;
  expectedState: string;
  timeoutMs: number;
  manifest: GitHubAppManifest;
  org?: string;
}
 
/**
 * Build the HTML page that auto-submits the manifest form to GitHub.
 */
function buildStartPage(manifest: GitHubAppManifest, state: string, org?: string): string {
  const githubUrl = org
    ? `https://github.com/organizations/${org}/settings/apps/new?state=${state}`
    : `https://github.com/settings/apps/new?state=${state}`;
 
  const manifestJson = JSON.stringify(manifest);
 
  return `<!DOCTYPE html>
<html>
<head>
  <title>Creating GitHub App...</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; text-align: center; padding: 50px; }
    .spinner { border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
    @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
  </style>
</head>
<body>
  <h1>Redirecting to GitHub...</h1>
  <div class="spinner"></div>
  <p>If you are not redirected automatically, click the button below.</p>
  <form id="manifest-form" action="${githubUrl}" method="post">
    <input type="hidden" name="manifest" value='${manifestJson.replace(/'/g, '&#39;')}'>
    <button type="submit" style="padding: 10px 20px; font-size: 16px; cursor: pointer;">Continue to GitHub</button>
  </form>
  <script>
    // Auto-submit the form after a brief delay
    setTimeout(function() {
      document.getElementById('manifest-form').submit();
    }, 500);
  </script>
</body>
</html>`;
}
 
/**
 * Create and start a local HTTP server for the manifest flow.
 * - GET / or /start: Serves the form that POSTs to GitHub
 * - GET /callback: Receives the callback from GitHub with the code
 */
export function startCallbackServer(options: ServerOptions): {
  server: Server;
  waitForCallback: Promise<CallbackResult>;
  close: () => void;
  startUrl: string;
} {
  let resolveCallback: (result: CallbackResult) => void;
  let rejectCallback: (error: Error) => void;
 
  const waitForCallback = new Promise<CallbackResult>((resolve, reject) => {
    resolveCallback = resolve;
    rejectCallback = reject;
  });
 
  const server = createServer((req: IncomingMessage, res: ServerResponse) => {
    const url = new URL(req.url || '/', `http://localhost:${options.port}`);
 
    // Serve the start page that auto-submits to GitHub
    if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/start')) {
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(buildStartPage(options.manifest, options.expectedState, options.org));
      return;
    }
 
    // Handle callback from GitHub
    if (req.method === 'GET' && url.pathname === '/callback') {
      const code = url.searchParams.get('code');
      const state = url.searchParams.get('state');
 
      // Validate state parameter (CSRF protection)
      if (state !== options.expectedState) {
        res.writeHead(400, { 'Content-Type': 'text/html' });
        res.end(`
          <!DOCTYPE html>
          <html>
          <head><title>Error</title></head>
          <body>
            <h1>Error: Invalid state parameter</h1>
            <p>This may be a CSRF attack. Please try again.</p>
          </body>
          </html>
        `);
        rejectCallback(new Error('Invalid state parameter - possible CSRF attack'));
        return;
      }
 
      if (!code) {
        res.writeHead(400, { 'Content-Type': 'text/html' });
        res.end(`
          <!DOCTYPE html>
          <html>
          <head><title>Error</title></head>
          <body>
            <h1>Error: Missing code parameter</h1>
            <p>GitHub did not provide the expected authorization code.</p>
          </body>
          </html>
        `);
        rejectCallback(new Error('Missing code parameter in callback'));
        return;
      }
 
      // Success - send response and resolve promise
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(`
        <!DOCTYPE html>
        <html>
        <head>
          <title>Success</title>
          <style>
            body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; text-align: center; padding: 50px; }
            h1 { color: #28a745; }
          </style>
        </head>
        <body>
          <h1>GitHub App Created!</h1>
          <p>You can close this window and return to the terminal.</p>
        </body>
        </html>
      `);
 
      resolveCallback({ code });
      return;
    }
 
    // 404 for anything else
    res.writeHead(404);
    res.end('Not found');
  });
 
  // Bind only to localhost for security
  server.listen(options.port, '127.0.0.1');
 
  // Set up timeout
  const timeoutId = setTimeout(() => {
    rejectCallback(new Error(`Timeout: No callback received within ${options.timeoutMs / 1000} seconds`));
    server.close();
  }, options.timeoutMs);
 
  const close = () => {
    clearTimeout(timeoutId);
    server.close();
  };
 
  const startUrl = `http://localhost:${options.port}/start`;
 
  return { server, waitForCallback, close, startUrl };
}