Skip to content

Create CLI tool for testing OAuth flows #13

@koistya

Description

@koistya

Description

Create a command-line tool that helps developers test OAuth flows interactively. This tool will make it easy to test different OAuth providers, debug issues, and understand how the library works.

What needs to be done

  1. Create CLI interface (src/cli.ts):

    • Accept provider configuration via flags
    • Support common OAuth providers with presets
    • Display received tokens (safely)
    • Save tokens to file
    • Support custom provider configuration
  2. Add binary entry to package.json:

    "bin": {
      "oauth-test": "./dist/cli.js"
    }
  3. Features to implement:

    • Interactive provider selection
    • Token exchange simulation
    • PKCE support testing
    • Error scenario testing
    • Token refresh testing

Why this matters

A CLI tool provides:

  • Quick way to test OAuth integrations
  • Learning tool for developers new to OAuth
  • Debugging aid for production issues
  • Provider compatibility testing
  • Token management utility

Without a CLI tool:

  • Developers must write custom code to test
  • Harder to debug OAuth issues
  • No standard way to test the library

Implementation considerations

⚠️ Note: This feature requires critical thinking during implementation. Consider:

  1. Security: How do we display tokens safely? Should we mask them by default?

  2. Provider registry: Should we maintain a registry of common providers or let users define everything?

  3. Alternative approach: Instead of a CLI, create a web-based testing tool with a nice UI

  4. Token storage: Where should we save tokens? Should we integrate with system keychains?

  5. Interactive vs scriptable: Should the CLI be interactive (prompts) or fully scriptable (flags only)?

Suggested implementation

#!/usr/bin/env node
// src/cli.ts

import { parseArgs } from "node:util";
import { getAuthCode, OAuthError } from "./index";
import { fileStore } from "./storage/file";
import * as readline from "node:readline/promises";

// Provider presets
const PROVIDERS = {
  github: {
    name: "GitHub",
    authUrl: "https://github.com/login/oauth/authorize",
    tokenUrl: "https://github.com/login/oauth/access_token",
    scopes: ["user", "repo", "gist"]
  },
  google: {
    name: "Google",
    authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
    tokenUrl: "https://oauth2.googleapis.com/token",
    scopes: ["openid", "email", "profile"]
  },
  microsoft: {
    name: "Microsoft",
    authUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
    tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
    scopes: ["User.Read", "Mail.Read"]
  }
};

interface CLIOptions {
  provider?: string;
  clientId?: string;
  clientSecret?: string;
  authUrl?: string;
  tokenUrl?: string;
  scopes?: string[];
  port?: number;
  save?: boolean;
  interactive?: boolean;
  pkce?: boolean;
  showTokens?: boolean;
}

class OAuthCLI {
  private rl?: readline.Interface;
  
  async run(options: CLIOptions) {
    console.log("🔐 OAuth Callback Test CLI\\n");
    
    // Interactive mode
    if (options.interactive) {
      this.rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
      });
      
      options = await this.promptForOptions(options);
      
      this.rl.close();
    }
    
    // Validate required options
    if (!options.clientId) {
      console.error("❌ Error: --client-id is required");
      process.exit(1);
    }
    
    // Build authorization URL
    const authUrl = this.buildAuthUrl(options);
    
    console.log("\\n📋 Configuration:");
    console.log(`  Provider: ${options.provider || 'custom'}`);
    console.log(`  Client ID: ${options.clientId}`);
    console.log(`  Scopes: ${options.scopes?.join(', ') || 'none'}`);
    console.log(`  PKCE: ${options.pkce ? 'enabled' : 'disabled'}`);
    console.log(`  Callback Port: ${options.port || 3000}`);
    
    console.log("\\n🌐 Opening browser for authorization...");
    console.log(`  URL: ${authUrl}\\n`);
    
    try {
      // Start OAuth flow
      const startTime = Date.now();
      const result = await getAuthCode({
        authorizationUrl: authUrl,
        port: options.port || 3000,
        validateState: true,
        pkce: options.pkce
      });
      
      const duration = Date.now() - startTime;
      
      console.log(`\\n✅ Authorization successful! (${duration}ms)`);
      console.log(`\\n📦 Callback parameters:`);
      
      for (const [key, value] of Object.entries(result)) {
        if (key === 'code' && !options.showTokens) {
          console.log(`  ${key}: ${value.substring(0, 8)}...`);
        } else {
          console.log(`  ${key}: ${value}`);
        }
      }
      
      // Exchange code for tokens (if token URL provided)
      if (options.tokenUrl && options.clientSecret) {
        console.log("\\n🔄 Exchanging authorization code for tokens...");
        const tokens = await this.exchangeCode(
          result.code!,
          options,
          result.code_verifier // From PKCE
        );
        
        console.log("\\n🎉 Token exchange successful!");
        
        if (options.showTokens) {
          console.log("\\n🔑 Received tokens:");
          console.log(JSON.stringify(tokens, null, 2));
        } else {
          console.log("\\n🔑 Received tokens: [use --show-tokens to display]");
        }
        
        // Save tokens if requested
        if (options.save) {
          const store = fileStore();
          await store.set(options.provider || 'custom', tokens);
          console.log("\\n💾 Tokens saved to ~/.mcp/tokens.json");
        }
      }
      
    } catch (error) {
      if (error instanceof OAuthError) {
        console.error(`\\n❌ OAuth Error: ${error.error}`);
        if (error.error_description) {
          console.error(`  Description: ${error.error_description}`);
        }
        if (error.error_uri) {
          console.error(`  More info: ${error.error_uri}`);
        }
      } else {
        console.error(`\\n❌ Error: ${error.message}`);
      }
      process.exit(1);
    }
  }
  
  private async promptForOptions(options: CLIOptions): Promise<CLIOptions> {
    // Interactive prompts for missing options
    if (!options.provider) {
      console.log("Select a provider:");
      Object.entries(PROVIDERS).forEach(([key, provider], index) => {
        console.log(`  ${index + 1}. ${provider.name}`);
      });
      console.log(`  ${Object.keys(PROVIDERS).length + 1}. Custom`);
      
      const choice = await this.rl!.question("\\nChoice: ");
      // ... handle choice
    }
    
    if (!options.clientId) {
      options.clientId = await this.rl!.question("Client ID: ");
    }
    
    // ... more prompts
    
    return options;
  }
  
  private buildAuthUrl(options: CLIOptions): string {
    const provider = options.provider ? PROVIDERS[options.provider] : null;
    const baseUrl = options.authUrl || provider?.authUrl;
    
    if (!baseUrl) {
      throw new Error("Authorization URL is required");
    }
    
    const url = new URL(baseUrl);
    url.searchParams.set("client_id", options.clientId!);
    url.searchParams.set("redirect_uri", `http://localhost:${options.port || 3000}/callback`);
    url.searchParams.set("response_type", "code");
    
    if (options.scopes && options.scopes.length > 0) {
      url.searchParams.set("scope", options.scopes.join(" "));
    }
    
    return url.toString();
  }
  
  private async exchangeCode(
    code: string,
    options: CLIOptions,
    codeVerifier?: string
  ): Promise<any> {
    // Token exchange implementation
    const response = await fetch(options.tokenUrl!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': 'application/json'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        client_id: options.clientId!,
        client_secret: options.clientSecret!,
        redirect_uri: `http://localhost:${options.port || 3000}/callback`,
        ...(codeVerifier && { code_verifier: codeVerifier })
      })
    });
    
    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Token exchange failed: ${error}`);
    }
    
    return response.json();
  }
}

// Parse CLI arguments
const { values } = parseArgs({
  options: {
    provider: { type: 'string', short: 'p' },
    'client-id': { type: 'string', short: 'c' },
    'client-secret': { type: 'string', short: 's' },
    'auth-url': { type: 'string' },
    'token-url': { type: 'string' },
    scopes: { type: 'string', multiple: true },
    port: { type: 'string' },
    save: { type: 'boolean' },
    interactive: { type: 'boolean', short: 'i' },
    pkce: { type: 'boolean' },
    'show-tokens': { type: 'boolean' },
    help: { type: 'boolean', short: 'h' }
  }
});

// Show help
if (values.help) {
  console.log(`
Usage: oauth-test [options]

Options:
  -p, --provider <name>      Use preset provider (github, google, microsoft)
  -c, --client-id <id>       OAuth client ID (required)
  -s, --client-secret <sec>  OAuth client secret (for token exchange)
  --auth-url <url>           Authorization endpoint URL
  --token-url <url>          Token endpoint URL
  --scopes <scope>           OAuth scopes (can be used multiple times)
  --port <port>              Callback server port (default: 3000)
  --save                     Save tokens to file
  -i, --interactive          Interactive mode with prompts
  --pkce                     Enable PKCE
  --show-tokens              Display full tokens (security warning!)
  -h, --help                 Show this help message

Examples:
  # Test GitHub OAuth
  oauth-test -p github -c YOUR_CLIENT_ID --scopes user repo

  # Interactive mode
  oauth-test -i

  # Custom provider with token exchange
  oauth-test --auth-url https://oauth.example.com/authorize \\
            --token-url https://oauth.example.com/token \\
            -c CLIENT_ID -s CLIENT_SECRET \\
            --pkce --save
`);
  process.exit(0);
}

// Run CLI
const cli = new OAuthCLI();
cli.run(values as CLIOptions).catch(console.error);

Testing the CLI

# Install globally for testing
npm link

# Test with GitHub
oauth-test -p github -c YOUR_CLIENT_ID

# Test PKCE
oauth-test -p google -c YOUR_CLIENT_ID --pkce

# Interactive mode
oauth-test -i

# Test error handling
oauth-test -c invalid_client_id

Skills required

  • TypeScript
  • Node.js CLI development
  • OAuth 2.0 protocol
  • Command-line UX design
  • Security best practices

Difficulty

Medium - Requires good understanding of CLI design and OAuth flows

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions