Skip to main content
Glama
authentication-validator.ts59.6 kB
import { TallyApiClient, TallyApiClientConfig } from './TallyApiClient'; import { OAuth2Config } from './TokenManager'; import { AuthenticationError, RateLimitError, NetworkError, TimeoutError, TallyApiError, } from '../models'; import https from 'https'; import { z, ZodIssue } from 'zod'; /** * Specific authentication error types for better error handling */ export class InvalidApiKeyError extends AuthenticationError { constructor(message: string, public readonly keyFormat?: string) { super(message); this.name = 'InvalidApiKeyError'; } } export class ExpiredTokenError extends AuthenticationError { constructor(message: string, public readonly expirationDate?: Date) { super(message); this.name = 'ExpiredTokenError'; } } export class InsufficientPermissionsError extends AuthenticationError { constructor(message: string, public readonly requiredScopes?: string[]) { super(message); this.name = 'InsufficientPermissionsError'; } } export class TokenRefreshError extends AuthenticationError { constructor(message: string, public readonly refreshToken?: string) { super(message); this.name = 'TokenRefreshError'; } } export class ApiKeyFormatError extends Error { constructor(message: string, public readonly providedKey?: string) { super(message); this.name = 'ApiKeyFormatError'; } } /** * Configuration for authentication validation */ export interface AuthenticationValidatorConfig { /** * Base URL for the Tally API (defaults to https://api.tally.so) */ baseURL?: string; /** * Request timeout in milliseconds (defaults to 10 seconds for auth calls) */ timeout?: number; /** * OAuth 2.0 configuration for token-based authentication */ oauth2Config?: OAuth2Config; /** * Enable debug logging for authentication operations */ debug?: boolean; /** * Maximum number of authentication attempts before giving up */ maxAuthAttempts?: number; /** * Delay between authentication retry attempts in milliseconds */ authRetryDelayMs?: number; /** * Authentication state management configuration */ authStateConfig?: AuthStateConfig; } /** * Result of API key format validation */ export interface ApiKeyValidationResult { /** * Whether the API key format is valid */ isValid: boolean; /** * Error message if validation failed */ error?: string; /** * Additional validation details */ details?: { hasCorrectPrefix: boolean; hasCorrectLength: boolean; hasValidCharacters: boolean; }; } /** * Result of authentication test */ export interface AuthenticationTestResult { /** * Whether authentication was successful */ isAuthenticated: boolean; /** * HTTP status code returned */ statusCode?: number; /** * Error message if authentication failed */ error?: string; /** * Response time in milliseconds */ responseTimeMs?: number; /** * Additional response data */ responseData?: any; } /** * Result of endpoint accessibility test */ export interface EndpointAccessibilityResult { /** * Whether the endpoint is accessible */ isAccessible: boolean; /** * HTTP status code returned */ statusCode?: number; /** * Error message if access failed */ error?: string; /** * Response time in milliseconds */ responseTimeMs?: number; /** * Whether the endpoint requires authentication */ requiresAuthentication?: boolean; /** * Response data if successful */ responseData?: any; /** * Whether the response format is valid */ isValidFormat?: boolean; /** * Format validation errors if any */ formatErrors?: string[]; } /** * Batch endpoint testing result */ export interface BatchEndpointTestResult { /** * Overall success of batch testing */ overallSuccess: boolean; /** * Individual endpoint results */ endpointResults: Record<string, EndpointAccessibilityResult>; /** * Total testing time in milliseconds */ totalTimeMs: number; /** * Summary statistics */ summary: { total: number; successful: number; failed: number; requiresAuth: number; }; /** * Any errors encountered during batch testing */ errors: string[]; } /** * Authentication scenario test configuration */ export interface AuthScenarioConfig { /** * Name of the scenario */ name: string; /** * Access token to use (can be invalid/malformed for testing) */ accessToken?: string; /** * Expected status code */ expectedStatusCode: number; /** * Whether this scenario should succeed */ shouldSucceed: boolean; /** * Description of what this scenario tests */ description: string; } /** * Error categorization and recovery information */ export interface AuthErrorInfo { /** * Error category */ category: 'format' | 'authentication' | 'authorization' | 'network' | 'rate_limit' | 'server' | 'unknown'; /** * Error severity level */ severity: 'low' | 'medium' | 'high' | 'critical'; /** * Whether this error is retryable */ isRetryable: boolean; /** * Suggested retry delay in milliseconds */ retryDelayMs?: number; /** * User-friendly error message */ userMessage: string; /** * Technical error details for debugging */ technicalDetails: string; /** * Suggested actions to resolve the error */ suggestedActions: string[]; /** * Related documentation or help links */ helpLinks?: string[]; } /** * Enhanced error result with categorization */ export interface CategorizedAuthError { /** * Original error */ originalError: Error; /** * Error information and categorization */ errorInfo: AuthErrorInfo; /** * HTTP status code if applicable */ statusCode?: number; /** * Timestamp when error occurred */ timestamp: Date; /** * Context information */ context?: { endpoint?: string; method?: string; requestId?: string; }; } /** * Result of connection testing */ export interface ConnectionTestResult { /** * Whether the connection was successful */ isConnected: boolean; /** * Response time in milliseconds */ responseTimeMs: number; /** * Error message if connection failed */ error?: string; /** * SSL/TLS certificate information */ sslInfo?: { valid: boolean; issuer?: string; subject?: string; validFrom?: Date; validTo?: Date; fingerprint?: string; }; /** * Network details */ networkInfo?: { hostname: string; port: number; protocol: string; ipAddress?: string; }; } /** * SSL/TLS certificate validation result */ export interface SSLValidationResult { /** * Whether the SSL certificate is valid */ isValid: boolean; /** * Certificate details */ certificate?: { issuer: string; subject: string; validFrom: Date; validTo: Date; fingerprint: string; serialNumber: string; }; /** * Validation errors if any */ errors: string[]; /** * Warnings about the certificate */ warnings: string[]; } /** * Comprehensive authentication validation results */ export interface ComprehensiveAuthValidationResult { /** * Overall validation success */ isValid: boolean; /** * API key format validation result */ apiKeyValidation: ApiKeyValidationResult; /** * Authentication test result */ authenticationTest: AuthenticationTestResult; /** * Connection test result */ connectionTest: ConnectionTestResult; /** * SSL validation result */ sslValidation: SSLValidationResult; /** * Endpoint accessibility results */ endpointTests: Record<string, EndpointAccessibilityResult>; /** * Total validation time in milliseconds */ totalTimeMs: number; /** * Summary of any errors encountered */ errors: string[]; } /** * Authentication cache entry */ export interface AuthCacheEntry { /** * Access token */ accessToken: string; /** * Token expiration timestamp */ expiresAt: Date; /** * When this entry was cached */ cachedAt: Date; /** * Token scopes if available */ scopes?: string[]; /** * Associated user info if available */ userInfo?: { id: string; email?: string; name?: string; }; } /** * Authentication state management configuration */ export interface AuthStateConfig { /** * Cache TTL in milliseconds (default: 1 hour) */ cacheTtlMs?: number; /** * Whether to enable token refresh */ enableTokenRefresh?: boolean; /** * Minimum time before expiry to attempt refresh (default: 5 minutes) */ refreshThresholdMs?: number; /** * Whether to cache authentication results */ enableCaching?: boolean; } /** * Environment validation result */ export interface EnvValidationResult { /** * Whether environment is properly configured */ isValid: boolean; /** * Missing environment variables */ missingVars: string[]; /** * Invalid environment variables */ invalidVars: { name: string; reason: string }[]; /** * Warnings about environment setup */ warnings: string[]; } /** * AuthenticationValidator - Comprehensive authentication validation for Tally.so API * * This class provides methods to validate API keys, test authentication flows, * and verify endpoint accessibility for the Tally.so integration. */ export class AuthenticationValidator { private apiClient: TallyApiClient; private config: Required<Omit<AuthenticationValidatorConfig, 'oauth2Config'>> & { oauth2Config?: OAuth2Config; }; /** * Authentication cache */ private authCache = new Map<string, AuthCacheEntry>(); /** * Authentication state configuration */ private authStateConfig: Required<AuthStateConfig>; /** * Create a new AuthenticationValidator instance * * @param config - Configuration options for the validator */ constructor(config: AuthenticationValidatorConfig = {}) { // Set default configuration values this.config = { baseURL: config.baseURL || 'https://api.tally.so', timeout: config.timeout || 10000, // 10 seconds for auth calls debug: config.debug || false, maxAuthAttempts: config.maxAuthAttempts || 3, authRetryDelayMs: config.authRetryDelayMs || 1000, authStateConfig: config.authStateConfig || {}, }; // Add oauth2Config only if provided if (config.oauth2Config) { this.config.oauth2Config = config.oauth2Config; } // Create the TallyApiClient with appropriate configuration const clientConfig: TallyApiClientConfig = { baseURL: this.config.baseURL, timeout: this.config.timeout, debug: this.config.debug, retryConfig: { maxAttempts: this.config.maxAuthAttempts, baseDelayMs: this.config.authRetryDelayMs, enableCircuitBreaker: false, // Disable circuit breaker for auth validation }, }; // Add oauth2Config only if it exists if (this.config.oauth2Config) { clientConfig.oauth2Config = this.config.oauth2Config; } this.apiClient = new TallyApiClient(clientConfig); // Initialize auth state configuration this.authStateConfig = { cacheTtlMs: 60 * 60 * 1000, // 1 hour enableTokenRefresh: true, refreshThresholdMs: 5 * 60 * 1000, // 5 minutes enableCaching: true, ...config.authStateConfig }; if (this.config.debug) { console.log('AuthenticationValidator initialized with config:', { baseURL: this.config.baseURL, timeout: this.config.timeout, authStateConfig: this.authStateConfig }); } } /** * Categorize and enhance authentication errors with recovery information * * @param error - The error to categorize * @param context - Additional context about where the error occurred * @returns Categorized error with recovery information */ public categorizeAuthError(error: Error, context?: { endpoint?: string; method?: string }): CategorizedAuthError { const timestamp = new Date(); let errorInfo: AuthErrorInfo; if (error instanceof ApiKeyFormatError) { errorInfo = { category: 'format', severity: 'high', isRetryable: false, userMessage: 'The API key format is invalid', technicalDetails: error.message, suggestedActions: [ 'Verify that your API key starts with "tally_"', 'Check that the API key contains only alphanumeric characters, underscores, and hyphens', 'Ensure the API key length is between 10 and 100 characters', 'Generate a new API key from your Tally dashboard if needed' ], helpLinks: ['https://tally.so/help/api-keys'] }; } else if (error instanceof InvalidApiKeyError) { errorInfo = { category: 'authentication', severity: 'high', isRetryable: false, userMessage: 'The API key is invalid or not recognized', technicalDetails: error.message, suggestedActions: [ 'Verify that you are using the correct API key', 'Check that the API key has not been revoked', 'Generate a new API key from your Tally dashboard', 'Ensure you are using the API key for the correct environment' ], helpLinks: ['https://tally.so/help/api-keys'] }; } else if (error instanceof ExpiredTokenError) { errorInfo = { category: 'authentication', severity: 'medium', isRetryable: true, retryDelayMs: 0, // Immediate retry after refresh userMessage: 'The access token has expired', technicalDetails: error.message, suggestedActions: [ 'Refresh the access token using your refresh token', 'Re-authenticate using the OAuth flow', 'Check token expiration times in your application' ], helpLinks: ['https://tally.so/help/oauth'] }; } else if (error instanceof InsufficientPermissionsError) { errorInfo = { category: 'authorization', severity: 'medium', isRetryable: false, userMessage: 'Insufficient permissions to access this resource', technicalDetails: error.message, suggestedActions: [ 'Request additional permissions from the workspace owner', 'Verify that your API key has the required scopes', 'Check that you are accessing resources within your permitted workspaces' ], helpLinks: ['https://tally.so/help/permissions'] }; } else if (error instanceof RateLimitError) { errorInfo = { category: 'rate_limit', severity: 'low', isRetryable: true, retryDelayMs: 60000, // 1 minute default userMessage: 'Rate limit exceeded', technicalDetails: error.message, suggestedActions: [ 'Wait before making additional requests', 'Implement exponential backoff in your application', 'Consider upgrading your API plan for higher rate limits', 'Optimize your API usage to reduce request frequency' ], helpLinks: ['https://tally.so/help/rate-limits'] }; } else if (error instanceof NetworkError) { errorInfo = { category: 'network', severity: 'medium', isRetryable: true, retryDelayMs: 5000, // 5 seconds userMessage: 'Network connection error', technicalDetails: error.message, suggestedActions: [ 'Check your internet connection', 'Verify that api.tally.so is accessible from your network', 'Check for firewall or proxy restrictions', 'Try again in a few moments' ] }; } else if (error instanceof TimeoutError) { errorInfo = { category: 'network', severity: 'medium', isRetryable: true, retryDelayMs: 3000, // 3 seconds userMessage: 'Request timed out', technicalDetails: error.message, suggestedActions: [ 'Check your network connection speed', 'Increase the timeout value in your configuration', 'Try again with a smaller request payload', 'Contact support if timeouts persist' ] }; } else if (error instanceof TallyApiError) { const statusCode = error.statusCode; if (statusCode !== undefined && statusCode >= 500) { errorInfo = { category: 'server', severity: 'high', isRetryable: true, retryDelayMs: 10000, // 10 seconds userMessage: 'Server error occurred', technicalDetails: error.message, suggestedActions: [ 'Try again in a few moments', 'Check the Tally status page for service issues', 'Contact support if the problem persists' ], helpLinks: ['https://status.tally.so'] }; } else if (statusCode === 401) { errorInfo = { category: 'authentication', severity: 'high', isRetryable: false, userMessage: 'Authentication failed', technicalDetails: error.message, suggestedActions: [ 'Verify your API key or access token', 'Check that your credentials have not expired', 'Re-authenticate if using OAuth' ] }; } else if (statusCode === 403) { errorInfo = { category: 'authorization', severity: 'medium', isRetryable: false, userMessage: 'Access forbidden', technicalDetails: error.message, suggestedActions: [ 'Check that you have permission to access this resource', 'Verify that your API key has the required scopes', 'Contact the workspace owner for access' ] }; } else { errorInfo = { category: 'unknown', severity: 'medium', isRetryable: false, userMessage: 'An API error occurred', technicalDetails: error.message, suggestedActions: [ 'Check the API documentation for this endpoint', 'Verify your request parameters', 'Contact support if the issue persists' ] }; } } else { errorInfo = { category: 'unknown', severity: 'medium', isRetryable: false, userMessage: 'An unexpected error occurred', technicalDetails: error.message, suggestedActions: [ 'Try again in a few moments', 'Check your network connection', 'Contact support if the problem persists' ] }; } return { originalError: error, errorInfo, ...(error instanceof TallyApiError && error.statusCode !== undefined ? { statusCode: error.statusCode } : {}), timestamp, ...(context ? { context } : {}) }; } /** * Create appropriate error based on API response * * @param statusCode - HTTP status code * @param message - Error message * @param responseData - Response data if available * @returns Appropriate error instance */ public createAuthError(statusCode: number, message: string, responseData?: any): Error { switch (statusCode) { case 401: if (message.toLowerCase().includes('expired')) { return new ExpiredTokenError(message); } else if (message.toLowerCase().includes('invalid') || message.toLowerCase().includes('key')) { return new InvalidApiKeyError(message); } else { return new AuthenticationError(message); } case 403: return new InsufficientPermissionsError(message); case 429: return new RateLimitError(message); case 408: case 504: return new TimeoutError(message); case 500: case 502: case 503: return new NetworkError(message); default: return new AuthenticationError(message); } } /** * Determine if an error should trigger a retry * * @param error - The error to check * @param attemptNumber - Current attempt number * @param maxAttempts - Maximum number of attempts * @returns Whether to retry and suggested delay */ public shouldRetryAuthError( error: Error, attemptNumber: number, maxAttempts: number ): { shouldRetry: boolean; delayMs: number } { if (attemptNumber >= maxAttempts) { return { shouldRetry: false, delayMs: 0 }; } const categorizedError = this.categorizeAuthError(error); if (!categorizedError.errorInfo.isRetryable) { return { shouldRetry: false, delayMs: 0 }; } const baseDelay = categorizedError.errorInfo.retryDelayMs || this.config.authRetryDelayMs; const exponentialDelay = baseDelay * Math.pow(2, attemptNumber - 1); const maxDelay = 60000; // 1 minute max const delayMs = Math.min(exponentialDelay, maxDelay); return { shouldRetry: true, delayMs }; } /** * Validate API key format according to Tally.so specifications * * @param apiKey - The API key to validate * @returns Validation result with details */ public validateApiKeyFormat(apiKey: string): ApiKeyValidationResult { if (!apiKey || typeof apiKey !== 'string') { return { isValid: false, error: 'API key must be a non-empty string', details: { hasCorrectPrefix: false, hasCorrectLength: false, hasValidCharacters: false, }, }; } const hasCorrectPrefix = apiKey.startsWith('tally_'); const hasCorrectLength = apiKey.length >= 10 && apiKey.length <= 100; // Reasonable bounds const hasValidCharacters = /^[a-zA-Z0-9_-]+$/.test(apiKey); const isValid = hasCorrectPrefix && hasCorrectLength && hasValidCharacters; const result: ApiKeyValidationResult = { isValid, details: { hasCorrectPrefix, hasCorrectLength, hasValidCharacters, }, }; if (!isValid) { const errors: string[] = []; if (!hasCorrectPrefix) { errors.push('API key must start with "tally_" prefix'); } if (!hasCorrectLength) { errors.push(`API key length must be between 10 and 100 characters (current: ${apiKey.length})`); } if (!hasValidCharacters) { errors.push('API key can only contain alphanumeric characters, underscores, and hyphens'); } result.error = errors.join('; '); } return result; } /** * Generate OAuth 2.0 authorization URL for authentication flow * * @param state - Optional state parameter for CSRF protection * @param scopes - Optional scopes to request (defaults to basic access) * @returns Authorization URL */ public generateAuthorizationUrl(state?: string, scopes?: string[]): string { if (!this.config.oauth2Config) { throw new AuthenticationError('OAuth 2.0 configuration is required for authorization URL generation'); } return this.apiClient.getAuthorizationUrl(state); } /** * Exchange authorization code for access token * * @param authorizationCode - Authorization code received from OAuth callback * @returns Token exchange result */ public async exchangeCodeForToken(authorizationCode: string): Promise<AuthenticationTestResult> { const startTime = Date.now(); try { if (!this.config.oauth2Config) { throw new AuthenticationError('OAuth 2.0 configuration is required for token exchange'); } const result = await this.apiClient.exchangeCodeForToken(authorizationCode); const responseTimeMs = Date.now() - startTime; return { isAuthenticated: true, statusCode: 200, responseTimeMs, responseData: result, }; } catch (error) { const responseTimeMs = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : 'Unknown error during token exchange'; const result: AuthenticationTestResult = { isAuthenticated: false, error: errorMessage, responseTimeMs, }; if (error instanceof TallyApiError) { result.statusCode = error.statusCode; } return result; } } /** * Test authentication with the provided access token * * @param accessToken - OAuth 2.0 access token to test * @returns Authentication test result */ public async testAuthentication(accessToken: string): Promise<AuthenticationTestResult> { const startTime = Date.now(); try { // Set the access token for testing this.apiClient.setAccessToken(accessToken); // Try to authenticate and get user info or workspaces const isAuthenticated = await this.apiClient.isAuthenticated(); const responseTimeMs = Date.now() - startTime; if (isAuthenticated) { // Try to get workspaces to verify the token works const workspaces = await this.apiClient.getWorkspaces({ limit: 1 }); return { isAuthenticated: true, statusCode: 200, responseTimeMs, responseData: { workspacesCount: workspaces.workspaces?.length || 0 }, }; } else { return { isAuthenticated: false, statusCode: 401, error: 'Authentication failed - token may be invalid or expired', responseTimeMs, }; } } catch (error) { const responseTimeMs = Date.now() - startTime; let statusCode: number | undefined; let errorMessage: string; if (error instanceof AuthenticationError) { statusCode = 401; errorMessage = error.message; } else if (error instanceof RateLimitError) { statusCode = 429; errorMessage = 'Rate limit exceeded during authentication test'; } else if (error instanceof NetworkError) { statusCode = 503; errorMessage = 'Network error during authentication test'; } else if (error instanceof TimeoutError) { statusCode = 408; errorMessage = 'Timeout during authentication test'; } else if (error instanceof TallyApiError) { statusCode = error.statusCode; errorMessage = error.message; } else { errorMessage = error instanceof Error ? error.message : 'Unknown authentication error'; } const result: AuthenticationTestResult = { isAuthenticated: false, error: errorMessage, responseTimeMs, }; if (statusCode !== undefined) { result.statusCode = statusCode; } return result; } } /** * Test accessibility of a specific API endpoint * * @param endpoint - The endpoint to test (e.g., '/forms', '/workspaces') * @param accessToken - Optional access token for authenticated requests * @returns Endpoint accessibility result */ public async testEndpointAccessibility( endpoint: string, accessToken?: string ): Promise<EndpointAccessibilityResult> { const startTime = Date.now(); try { // Set access token if provided if (accessToken) { this.apiClient.setAccessToken(accessToken); } else { this.apiClient.setAccessToken(''); // Clear token for unauthenticated test } // Make a GET request to the endpoint const response = await this.apiClient.get(endpoint); const responseTimeMs = Date.now() - startTime; return { isAccessible: true, statusCode: response.status, responseTimeMs, requiresAuthentication: !!accessToken, }; } catch (error) { const responseTimeMs = Date.now() - startTime; let statusCode: number | undefined; let errorMessage: string; let requiresAuthentication = false; if (error instanceof AuthenticationError) { statusCode = 401; errorMessage = 'Endpoint requires authentication'; requiresAuthentication = true; } else if (error instanceof TallyApiError) { statusCode = error.statusCode; errorMessage = error.message; requiresAuthentication = statusCode === 401 || statusCode === 403; } else if (error instanceof NetworkError) { statusCode = 503; errorMessage = 'Network error accessing endpoint'; } else if (error instanceof TimeoutError) { statusCode = 408; errorMessage = 'Timeout accessing endpoint'; } else { errorMessage = error instanceof Error ? error.message : 'Unknown error accessing endpoint'; } return { isAccessible: false, statusCode, error: errorMessage, responseTimeMs, requiresAuthentication, }; } } /** * Validate response format using appropriate Zod schema * * @param endpoint - The endpoint that was called * @param responseData - The response data to validate * @returns Validation result */ private validateResponseFormat(endpoint: string, responseData: any): { isValid: boolean; errors: string[] } { try { // Import schemas dynamically to avoid circular dependencies const { TallyFormsResponseSchema, TallyWorkspacesResponseSchema, TallySubmissionsResponseSchema } = require('../models'); let schema; if (endpoint.includes('/forms')) { schema = TallyFormsResponseSchema; } else if (endpoint.includes('/workspaces')) { schema = TallyWorkspacesResponseSchema; } else if (endpoint.includes('/submissions')) { schema = TallySubmissionsResponseSchema; } else { // No specific schema available, just check if it's an object return { isValid: typeof responseData === 'object' && responseData !== null, errors: typeof responseData !== 'object' ? ['Response is not an object'] : [], }; } const validation = schema.safeParse(responseData); return { isValid: validation.success, errors: validation.success ? [] : validation.error.errors.map((e: ZodIssue) => `${e.path.join('.')}: ${e.message}`), }; } catch (error) { return { isValid: false, errors: [`Schema validation error: ${error instanceof Error ? error.message : 'Unknown error'}`], }; } } /** * Test accessibility of a specific API endpoint with enhanced validation * * @param endpoint - The endpoint to test (e.g., '/forms', '/workspaces') * @param accessToken - Optional access token for authenticated requests * @param validateFormat - Whether to validate response format using Zod schemas * @returns Enhanced endpoint accessibility result */ public async testEndpointAccessibilityEnhanced( endpoint: string, accessToken?: string, validateFormat: boolean = false ): Promise<EndpointAccessibilityResult> { const startTime = Date.now(); try { // Set access token if provided if (accessToken) { this.apiClient.setAccessToken(accessToken); } else { this.apiClient.setAccessToken(''); // Clear token for unauthenticated test } // Make a GET request to the endpoint const response = await this.apiClient.get(endpoint); const responseTimeMs = Date.now() - startTime; const result: EndpointAccessibilityResult = { isAccessible: true, statusCode: response.status, responseTimeMs, requiresAuthentication: !!accessToken, responseData: response.data, }; // Validate response format if requested if (validateFormat) { const formatValidation = this.validateResponseFormat(endpoint, response.data); result.isValidFormat = formatValidation.isValid; result.formatErrors = formatValidation.errors; } return result; } catch (error) { const responseTimeMs = Date.now() - startTime; let statusCode: number | undefined; let errorMessage: string; let requiresAuthentication = false; if (error instanceof AuthenticationError) { statusCode = 401; errorMessage = 'Endpoint requires authentication'; requiresAuthentication = true; } else if (error instanceof TallyApiError) { statusCode = error.statusCode; errorMessage = error.message; requiresAuthentication = statusCode === 401 || statusCode === 403; } else if (error instanceof NetworkError) { statusCode = 503; errorMessage = 'Network error accessing endpoint'; } else if (error instanceof TimeoutError) { statusCode = 408; errorMessage = 'Timeout accessing endpoint'; } else { errorMessage = error instanceof Error ? error.message : 'Unknown error accessing endpoint'; } return { isAccessible: false, statusCode, error: errorMessage, responseTimeMs, requiresAuthentication, }; } } /** * Test the /forms endpoint specifically * * @param accessToken - Access token for authentication * @param options - Query options for the forms endpoint * @returns Endpoint test result with forms-specific validation */ public async testFormsEndpoint( accessToken: string, options: { page?: number; limit?: number; workspaceId?: string } = {} ): Promise<EndpointAccessibilityResult> { const queryParams = new URLSearchParams(); if (options.page) queryParams.append('page', options.page.toString()); if (options.limit) queryParams.append('limit', options.limit.toString()); if (options.workspaceId) queryParams.append('workspaceId', options.workspaceId); const endpoint = `/forms${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; return this.testEndpointAccessibilityEnhanced(endpoint, accessToken, true); } /** * Test the /workspaces endpoint specifically * * @param accessToken - Access token for authentication * @param options - Query options for the workspaces endpoint * @returns Endpoint test result with workspaces-specific validation */ public async testWorkspacesEndpoint( accessToken: string, options: { page?: number; limit?: number } = {} ): Promise<EndpointAccessibilityResult> { const queryParams = new URLSearchParams(); if (options.page) queryParams.append('page', options.page.toString()); if (options.limit) queryParams.append('limit', options.limit.toString()); const endpoint = `/workspaces${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; return this.testEndpointAccessibilityEnhanced(endpoint, accessToken, true); } /** * Test multiple endpoints in batch * * @param endpoints - List of endpoints to test * @param accessToken - Access token for authentication * @param validateFormat - Whether to validate response formats * @returns Batch testing results */ public async testEndpointsBatch( endpoints: string[], accessToken?: string, validateFormat: boolean = false ): Promise<BatchEndpointTestResult> { const startTime = Date.now(); const endpointResults: Record<string, EndpointAccessibilityResult> = {}; const errors: string[] = []; // Test each endpoint for (const endpoint of endpoints) { try { endpointResults[endpoint] = await this.testEndpointAccessibilityEnhanced(endpoint, accessToken, validateFormat); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; endpointResults[endpoint] = { isAccessible: false, error: errorMessage, responseTimeMs: 0, }; errors.push(`${endpoint}: ${errorMessage}`); } } const totalTimeMs = Date.now() - startTime; const results = Object.values(endpointResults); const successful = results.filter(r => r.isAccessible).length; const failed = results.filter(r => !r.isAccessible).length; const requiresAuth = results.filter(r => r.requiresAuthentication).length; return { overallSuccess: failed === 0, endpointResults, totalTimeMs, summary: { total: endpoints.length, successful, failed, requiresAuth, }, errors, }; } /** * Test different authentication scenarios * * @param endpoint - Endpoint to test * @param scenarios - Authentication scenarios to test * @returns Results for each scenario */ public async testAuthenticationScenarios( endpoint: string, scenarios?: AuthScenarioConfig[] ): Promise<Record<string, EndpointAccessibilityResult & { scenarioConfig: AuthScenarioConfig }>> { const defaultScenarios: AuthScenarioConfig[] = [ { name: 'valid_token', accessToken: 'tally_valid_test_token_12345', expectedStatusCode: 200, shouldSucceed: true, description: 'Test with a valid access token format', }, { name: 'invalid_token', accessToken: 'tally_invalid_token_xyz', expectedStatusCode: 401, shouldSucceed: false, description: 'Test with an invalid access token', }, { name: 'malformed_token', accessToken: 'not_a_tally_token', expectedStatusCode: 401, shouldSucceed: false, description: 'Test with a malformed token (wrong prefix)', }, { name: 'empty_token', accessToken: '', expectedStatusCode: 401, shouldSucceed: false, description: 'Test with an empty token', }, { name: 'no_token', accessToken: undefined, expectedStatusCode: 401, shouldSucceed: false, description: 'Test without any authentication token', }, ]; const testScenarios = scenarios || defaultScenarios; const results: Record<string, EndpointAccessibilityResult & { scenarioConfig: AuthScenarioConfig }> = {}; for (const scenario of testScenarios) { try { const result = await this.testEndpointAccessibilityEnhanced(endpoint, scenario.accessToken); results[scenario.name] = { ...result, scenarioConfig: scenario, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; results[scenario.name] = { isAccessible: false, error: errorMessage, responseTimeMs: 0, scenarioConfig: scenario, }; } } return results; } /** * Test network connectivity to the Tally API server * * @param hostname - Hostname to test (defaults to api.tally.so) * @param port - Port to test (defaults to 443 for HTTPS) * @returns Connection test result */ public async testConnection( hostname: string = 'api.tally.so', port: number = 443 ): Promise<ConnectionTestResult> { const startTime = Date.now(); return new Promise((resolve) => { const options = { hostname, port, method: 'HEAD', timeout: this.config.timeout, rejectUnauthorized: true, // Ensure SSL certificate validation }; const req = https.request(options, (res) => { const responseTimeMs = Date.now() - startTime; const socket = res.socket as any; const sslInfo = socket.getPeerCertificate ? { valid: socket.authorized || false, issuer: socket.getPeerCertificate()?.issuer?.CN, subject: socket.getPeerCertificate()?.subject?.CN, validFrom: socket.getPeerCertificate()?.valid_from ? new Date(socket.getPeerCertificate().valid_from) : undefined, validTo: socket.getPeerCertificate()?.valid_to ? new Date(socket.getPeerCertificate().valid_to) : undefined, fingerprint: socket.getPeerCertificate()?.fingerprint, } : undefined; resolve({ isConnected: true, responseTimeMs, sslInfo, networkInfo: { hostname, port, protocol: 'https', ipAddress: socket.remoteAddress, }, }); }); req.on('error', (error) => { const responseTimeMs = Date.now() - startTime; resolve({ isConnected: false, responseTimeMs, error: error.message, networkInfo: { hostname, port, protocol: 'https', }, }); }); req.on('timeout', () => { req.destroy(); const responseTimeMs = Date.now() - startTime; resolve({ isConnected: false, responseTimeMs, error: `Connection timeout after ${this.config.timeout}ms`, networkInfo: { hostname, port, protocol: 'https', }, }); }); req.end(); }); } /** * Validate SSL/TLS certificate for the Tally API * * @param hostname - Hostname to validate (defaults to api.tally.so) * @param port - Port to validate (defaults to 443) * @returns SSL validation result */ public async validateSSLCertificate( hostname: string = 'api.tally.so', port: number = 443 ): Promise<SSLValidationResult> { return new Promise((resolve) => { const options = { hostname, port, method: 'HEAD', timeout: this.config.timeout, rejectUnauthorized: true, }; const req = https.request(options, (res) => { const socket = res.socket as any; const cert = socket.getPeerCertificate(); if (!cert || Object.keys(cert).length === 0) { resolve({ isValid: false, errors: ['No SSL certificate found'], warnings: [], }); return; } const now = new Date(); const validFrom = new Date(cert.valid_from); const validTo = new Date(cert.valid_to); const errors: string[] = []; const warnings: string[] = []; // Check certificate validity period if (now < validFrom) { errors.push('Certificate is not yet valid'); } if (now > validTo) { errors.push('Certificate has expired'); } // Check if certificate is expiring soon (within 30 days) const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); if (validTo < thirtyDaysFromNow) { warnings.push('Certificate expires within 30 days'); } // Check hostname match if (cert.subject?.CN !== hostname && !cert.subjectaltname?.includes(hostname)) { errors.push(`Certificate hostname mismatch: expected ${hostname}, got ${cert.subject?.CN}`); } // Check if certificate is self-signed if (cert.issuer?.CN === cert.subject?.CN) { warnings.push('Certificate appears to be self-signed'); } const isValid = socket.authorized && errors.length === 0; resolve({ isValid, certificate: { issuer: cert.issuer?.CN || 'Unknown', subject: cert.subject?.CN || 'Unknown', validFrom, validTo, fingerprint: cert.fingerprint || '', serialNumber: cert.serialNumber || '', }, errors, warnings, }); }); req.on('error', (error) => { resolve({ isValid: false, errors: [`SSL connection error: ${error.message}`], warnings: [], }); }); req.on('timeout', () => { req.destroy(); resolve({ isValid: false, errors: [`SSL validation timeout after ${this.config.timeout}ms`], warnings: [], }); }); req.end(); }); } /** * Test connection with retry logic and exponential backoff * * @param hostname - Hostname to test * @param port - Port to test * @param maxRetries - Maximum number of retry attempts * @returns Connection test result with retry information */ public async testConnectionWithRetry( hostname: string = 'api.tally.so', port: number = 443, maxRetries: number = 3 ): Promise<ConnectionTestResult & { retryAttempts: number }> { let lastError: string = ''; let totalResponseTime = 0; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const result = await this.testConnection(hostname, port); if (result.isConnected) { return { ...result, responseTimeMs: totalResponseTime + result.responseTimeMs, retryAttempts: attempt, }; } lastError = result.error || 'Connection failed'; totalResponseTime += result.responseTimeMs; // Wait before retrying (exponential backoff) if (attempt < maxRetries) { const delay = this.config.authRetryDelayMs * Math.pow(2, attempt); await new Promise(resolve => setTimeout(resolve, delay)); } } catch (error) { lastError = error instanceof Error ? error.message : 'Unknown error'; if (attempt < maxRetries) { const delay = this.config.authRetryDelayMs * Math.pow(2, attempt); await new Promise(resolve => setTimeout(resolve, delay)); } } } return { isConnected: false, responseTimeMs: totalResponseTime, error: `Failed after ${maxRetries + 1} attempts: ${lastError}`, retryAttempts: maxRetries + 1, networkInfo: { hostname, port, protocol: 'https', }, }; } /** * Perform comprehensive authentication validation * * @param accessToken - Access token to validate * @param testEndpoints - List of endpoints to test accessibility * @returns Comprehensive validation results */ public async performComprehensiveValidation( accessToken: string, testEndpoints: string[] = ['/workspaces', '/forms'] ): Promise<ComprehensiveAuthValidationResult> { const startTime = Date.now(); const errors: string[] = []; // 1. Validate API key format const apiKeyValidation = this.validateApiKeyFormat(accessToken); if (!apiKeyValidation.isValid && apiKeyValidation.error) { errors.push(`API Key Format: ${apiKeyValidation.error}`); } // 2. Test connection const connectionTest = await this.testConnection(); if (!connectionTest.isConnected && connectionTest.error) { errors.push(`Connection: ${connectionTest.error}`); } // 3. Validate SSL certificate const sslValidation = await this.validateSSLCertificate(); if (!sslValidation.isValid) { errors.push(`SSL: ${sslValidation.errors.join(', ')}`); } // 4. Test authentication const authenticationTest = await this.testAuthentication(accessToken); if (!authenticationTest.isAuthenticated && authenticationTest.error) { errors.push(`Authentication: ${authenticationTest.error}`); } // 5. Test endpoint accessibility const endpointTests: Record<string, EndpointAccessibilityResult> = {}; for (const endpoint of testEndpoints) { try { endpointTests[endpoint] = await this.testEndpointAccessibility(endpoint, accessToken); if (!endpointTests[endpoint].isAccessible && endpointTests[endpoint].error) { errors.push(`Endpoint ${endpoint}: ${endpointTests[endpoint].error}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; endpointTests[endpoint] = { isAccessible: false, error: errorMessage, responseTimeMs: 0, }; errors.push(`Endpoint ${endpoint}: ${errorMessage}`); } } const totalTimeMs = Date.now() - startTime; const isValid = apiKeyValidation.isValid && connectionTest.isConnected && sslValidation.isValid && authenticationTest.isAuthenticated && Object.values(endpointTests).every(result => result.isAccessible); return { isValid, apiKeyValidation, connectionTest, sslValidation, authenticationTest, endpointTests, totalTimeMs, errors, }; } /** * Get the underlying TallyApiClient instance * * @returns TallyApiClient instance */ public getApiClient(): TallyApiClient { return this.apiClient; } /** * Get the current configuration * * @returns Current configuration */ public getConfig(): Readonly<Required<Omit<AuthenticationValidatorConfig, 'oauth2Config'>> & { oauth2Config?: OAuth2Config }> { return { ...this.config }; } /** * Validate environment variables for authentication */ public validateEnvironment(): EnvValidationResult { const result: EnvValidationResult = { isValid: true, missingVars: [], invalidVars: [], warnings: [] }; // Check for API key const apiKey = process.env.TALLY_API_KEY; if (!apiKey) { result.missingVars.push('TALLY_API_KEY'); result.isValid = false; } else { const validation = this.validateApiKeyFormat(apiKey); if (!validation.isValid) { result.invalidVars.push({ name: 'TALLY_API_KEY', reason: validation.error || 'Invalid format' }); result.isValid = false; } } // Check OAuth configuration if enabled if (this.config.oauth2Config) { const requiredOAuthVars = ['TALLY_CLIENT_ID', 'TALLY_CLIENT_SECRET']; for (const varName of requiredOAuthVars) { if (!process.env[varName]) { result.missingVars.push(varName); result.isValid = false; } } const redirectUri = process.env.TALLY_REDIRECT_URI; if (!redirectUri) { result.warnings.push('TALLY_REDIRECT_URI not set - OAuth flow may not work correctly'); } else if (!redirectUri.startsWith('https://') && !redirectUri.startsWith('http://localhost')) { result.warnings.push('TALLY_REDIRECT_URI should use HTTPS in production'); } } // Check optional configuration const baseUrl = process.env.TALLY_API_BASE_URL; if (baseUrl && !baseUrl.startsWith('https://')) { result.warnings.push('TALLY_API_BASE_URL should use HTTPS'); } this.logAuthentication('info', 'Environment validation completed', { isValid: result.isValid, missingVars: result.missingVars, invalidVars: result.invalidVars.length, warnings: result.warnings.length }); return result; } /** * Cache authentication result */ private cacheAuthResult( accessToken: string, expiresInSeconds?: number, userInfo?: AuthCacheEntry['userInfo'] ): void { if (!this.authStateConfig.enableCaching) { return; } const now = new Date(); const expiresAt = new Date(now.getTime() + (expiresInSeconds ? expiresInSeconds * 1000 : this.authStateConfig.cacheTtlMs)); const cacheEntry: AuthCacheEntry = { accessToken, expiresAt, cachedAt: now, userInfo }; this.authCache.set(accessToken, cacheEntry); this.logAuthentication('debug', 'Authentication result cached', { expiresAt: expiresAt.toISOString(), hasUserInfo: !!userInfo }); // Clean up expired entries this.cleanupExpiredCache(); } /** * Get cached authentication result */ private getCachedAuth(accessToken: string): AuthCacheEntry | null { if (!this.authStateConfig.enableCaching) { return null; } const entry = this.authCache.get(accessToken); if (!entry) { return null; } const now = new Date(); if (now >= entry.expiresAt) { this.authCache.delete(accessToken); this.logAuthentication('debug', 'Cached authentication expired', { accessToken: accessToken.substring(0, 10) + '...', expiredAt: entry.expiresAt.toISOString() }); return null; } return entry; } /** * Check if token needs refresh */ private needsTokenRefresh(cacheEntry: AuthCacheEntry): boolean { if (!this.authStateConfig.enableTokenRefresh) { return false; } const now = new Date(); const refreshThreshold = new Date(cacheEntry.expiresAt.getTime() - this.authStateConfig.refreshThresholdMs); return now >= refreshThreshold; } /** * Refresh authentication token */ public async refreshAuthToken(refreshToken: string): Promise<AuthenticationTestResult> { const startTime = Date.now(); this.logAuthentication('info', 'Attempting token refresh', { refreshToken: refreshToken.substring(0, 10) + '...' }); try { if (!this.config.oauth2Config) { throw new Error('OAuth configuration required for token refresh'); } const refreshData = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.config.oauth2Config.clientId, client_secret: this.config.oauth2Config.clientSecret }; const response = await this.apiClient.post( '/oauth/token', refreshData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); const responseTime = Date.now() - startTime; if (response.status === 200) { const tokenData = response.data; // Cache the new token this.cacheAuthResult( tokenData.access_token, tokenData.expires_in, tokenData.user ); this.logAuthentication('info', 'Token refresh successful', { responseTime, expiresIn: tokenData.expires_in }); return { isAuthenticated: true, statusCode: response.status, responseTimeMs: responseTime, responseData: tokenData }; } else { const error = new TokenRefreshError(`Token refresh failed: ${response.data?.error || 'Unknown error'}`, refreshToken); this.logAuthentication('error', 'Token refresh failed', { statusCode: response.status, error: error.message, responseTime }); return { isAuthenticated: false, statusCode: response.status, error: error.message, responseTimeMs: responseTime }; } } catch (error: any) { const responseTime = Date.now() - startTime; const authError = new TokenRefreshError(`Token refresh error: ${error.message}`, refreshToken); this.logAuthentication('error', 'Token refresh error', { error: error.message, responseTime }); return { isAuthenticated: false, error: authError.message, responseTimeMs: responseTime }; } } /** * Enhanced authentication test with caching and refresh */ public async testAuthenticationWithCache(accessToken: string, forceRefresh: boolean = false): Promise<AuthenticationTestResult> { this.logAuthentication('info', 'Starting authentication test with cache', { accessToken: accessToken.substring(0, 10) + '...', forceRefresh }); // Check cache first const cachedAuth = this.getCachedAuth(accessToken); if (cachedAuth && !forceRefresh) { if (this.needsTokenRefresh(cachedAuth)) { this.logAuthentication('info', 'Token needs refresh', { expiresAt: cachedAuth.expiresAt.toISOString() }); // TODO: Implement refresh logic when refresh token is available } else { this.logAuthentication('info', 'Using cached authentication', { cachedAt: cachedAuth.cachedAt.toISOString(), expiresAt: cachedAuth.expiresAt.toISOString() }); return { isAuthenticated: true, statusCode: 200, responseTimeMs: 0, // Cache hit responseData: cachedAuth.userInfo }; } } // Perform actual authentication test const result = await this.testAuthentication(accessToken); // Cache successful result if (result.isAuthenticated && result.responseData) { this.cacheAuthResult(accessToken, undefined, result.responseData); } return result; } /** * Clean up expired cache entries */ private cleanupExpiredCache(): void { const now = new Date(); let removedCount = 0; for (const [token, entry] of this.authCache.entries()) { if (now >= entry.expiresAt) { this.authCache.delete(token); removedCount++; } } if (removedCount > 0) { this.logAuthentication('debug', 'Cleaned up expired cache entries', { removedCount, remainingCount: this.authCache.size }); } } /** * Clear authentication cache */ public clearAuthCache(): void { const cacheSize = this.authCache.size; this.authCache.clear(); this.logAuthentication('info', 'Authentication cache cleared', { clearedEntries: cacheSize }); } /** * Get authentication cache stats */ public getAuthCacheStats(): { totalEntries: number; validEntries: number; expiredEntries: number; oldestEntry?: Date; newestEntry?: Date; } { const now = new Date(); let validEntries = 0; let expiredEntries = 0; let oldestEntry: Date | undefined; let newestEntry: Date | undefined; for (const entry of this.authCache.values()) { if (now < entry.expiresAt) { validEntries++; } else { expiredEntries++; } if (!oldestEntry || entry.cachedAt < oldestEntry) { oldestEntry = entry.cachedAt; } if (!newestEntry || entry.cachedAt > newestEntry) { newestEntry = entry.cachedAt; } } return { totalEntries: this.authCache.size, validEntries, expiredEntries, oldestEntry, newestEntry }; } /** * Comprehensive logging for authentication events */ private logAuthentication( level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, any> ): void { if (!this.config.debug && level === 'debug') { return; } const timestamp = new Date().toISOString(); const logEntry = { timestamp, level, component: 'AuthenticationValidator', message, ...context }; // Use console for now, but this could be replaced with a proper logger const logMethod = console[level] || console.log; logMethod(`[${timestamp}] [${level.toUpperCase()}] AuthenticationValidator: ${message}`, context || ''); // In a production environment, you might want to: // - Send logs to a logging service (e.g., Winston, Bunyan) // - Include correlation IDs for request tracing // - Implement log sampling for high-volume scenarios // - Add structured logging with consistent fields } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/learnwithcc/tally-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server