auth.ts•6.63 kB
import { LinearClient } from '@linear/sdk';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
/**
 * Solution Attempts:
 * 
 * 1. OAuth Flow with Browser (Initial Attempt)
 * - Used browser redirect and local server for OAuth flow
 * - Issues: Browser extensions interfering, CORS issues
 * - Status: Failed - Browser extensions and CORS blocking requests
 * 
 * 2. Personal Access Token (Current Attempt)
 * - Using PAT for initial integration tests
 * - Simpler approach without browser interaction
 * - Status: Working - Successfully authenticates and makes API calls
 * 
 * 3. Direct OAuth Token Exchange (Current Attempt)
 * - Using form-urlencoded content type as required by Linear
 * - Status: In Progress - Testing token exchange
 */
export interface OAuthConfig {
  type: 'oauth';
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}
export interface PersonalAccessTokenConfig {
  type: 'pat';
  accessToken: string;
}
export type AuthConfig = OAuthConfig | PersonalAccessTokenConfig;
export interface TokenData {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}
export class LinearAuth {
  private static readonly OAUTH_AUTH_URL = 'https://linear.app/oauth';
  private static readonly OAUTH_TOKEN_URL = 'https://api.linear.app';
  private config?: AuthConfig;
  private tokenData?: TokenData;
  private linearClient?: LinearClient;
  constructor() {}
  public getAuthorizationUrl(): string {
    if (!this.config || this.config.type !== 'oauth') {
      throw new McpError(
        ErrorCode.InvalidRequest,
        'OAuth config not initialized'
      );
    }
    const params = new URLSearchParams({
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      response_type: 'code',
      scope: 'read,write,issues:create,offline_access',
      actor: 'application', // Enable OAuth Actor Authorization
      state: this.generateState(),
      access_type: 'offline',
    });
    return `${LinearAuth.OAUTH_AUTH_URL}/authorize?${params.toString()}`;
  }
  public async handleCallback(code: string): Promise<void> {
    if (!this.config || this.config.type !== 'oauth') {
      throw new McpError(
        ErrorCode.InvalidRequest,
        'OAuth config not initialized'
      );
    }
    try {
      const params = new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
        redirect_uri: this.config.redirectUri,
        code,
        access_type: 'offline'
      });
      const response = await fetch(`${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'Accept': 'application/json'
        },
        body: params.toString()
      });
      if (!response.ok) {
        const errorText = await response.text();
        throw new Error(`Token request failed: ${response.statusText}. Response: ${errorText}`);
      }
      const data = await response.json();
      this.tokenData = {
        accessToken: data.access_token,
        refreshToken: data.refresh_token,
        expiresAt: Date.now() + data.expires_in * 1000,
      };
      this.linearClient = new LinearClient({
        accessToken: this.tokenData.accessToken,
      });
    } catch (error) {
      throw new McpError(
        ErrorCode.InternalError,
        `OAuth token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}`
      );
    }
  }
  public async refreshAccessToken(): Promise<void> {
    if (!this.config || this.config.type !== 'oauth' || !this.tokenData?.refreshToken) {
      throw new McpError(
        ErrorCode.InvalidRequest,
        'OAuth not initialized or no refresh token available'
      );
    }
    try {
      const params = new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
        refresh_token: this.tokenData.refreshToken
      });
      const response = await fetch(`${LinearAuth.OAUTH_TOKEN_URL}/oauth/token`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'Accept': 'application/json'
        },
        body: params.toString()
      });
      if (!response.ok) {
        const errorText = await response.text();
        throw new Error(`Token refresh failed: ${response.statusText}. Response: ${errorText}`);
      }
      const data = await response.json();
      this.tokenData = {
        accessToken: data.access_token,
        refreshToken: data.refresh_token,
        expiresAt: Date.now() + data.expires_in * 1000,
      };
      this.linearClient = new LinearClient({
        accessToken: this.tokenData.accessToken,
      });
    } catch (error) {
      throw new McpError(
        ErrorCode.InternalError,
        `Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`
      );
    }
  }
  public initialize(config: AuthConfig): void {
    if (config.type === 'pat') {
      // Personal Access Token flow
      this.tokenData = {
        accessToken: config.accessToken,
        refreshToken: '', // Not needed for PAT
        expiresAt: Number.MAX_SAFE_INTEGER, // PATs don't expire
      };
      this.linearClient = new LinearClient({
        accessToken: config.accessToken,
      });
    } else {
      // OAuth flow
      if (!config.clientId || !config.clientSecret || !config.redirectUri) {
        throw new McpError(
          ErrorCode.InvalidParams,
          'Missing required OAuth parameters: clientId, clientSecret, redirectUri'
        );
      }
      this.config = config;
    }
  }
  public getClient(): LinearClient {
    if (!this.linearClient) {
      throw new McpError(
        ErrorCode.InvalidRequest,
        'Linear client not initialized'
      );
    }
    return this.linearClient;
  }
  public isAuthenticated(): boolean {
    return !!this.linearClient && !!this.tokenData;
  }
  public needsTokenRefresh(): boolean {
    if (!this.tokenData || !this.config || this.config.type === 'pat') return false;
    return Date.now() >= this.tokenData.expiresAt - 300000; // Refresh 5 minutes before expiry
  }
  // For testing purposes
  public setTokenData(tokenData: TokenData): void {
    this.tokenData = tokenData;
    this.linearClient = new LinearClient({
      accessToken: tokenData.accessToken,
    });
  }
  private generateState(): string {
    return Math.random().toString(36).substring(2, 15);
  }
}