UNPKG

token-guardian

Version:

A comprehensive solution for protecting and managing API tokens and secrets

881 lines (781 loc) 28.7 kB
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import { ServiceRotator } from '../ServiceRotator'; import { RotationResult } from '../../interfaces/RotationResult'; import { createLogger, Logger, transports, format } from 'winston'; /** * GitHub OAuth2 configuration interface */ export interface GitHubOAuth2Config { clientId: string; clientSecret: string; redirectUri: string; scope: string[]; tokenEndpoint?: string; authorizationEndpoint?: string; userEndpoint?: string; tokenInfoEndpoint?: string; } /** * GitHub API Token information */ export interface GitHubToken { accessToken: string; refreshToken?: string; expiresIn?: number; expiresAt?: Date; tokenType: string; scope: string[]; } /** * Rate limit information from GitHub API */ export interface RateLimitInfo { limit: number; remaining: number; reset: number; // Unix timestamp used: number; resource: string; } /** * Retry configuration options */ export interface RetryOptions { maxRetries: number; initialDelayMs: number; maxDelayMs: number; backoffFactor: number; retryStatusCodes: number[]; } /** * Rotator for GitHub tokens with OAuth2 support */ export class GitHubRotator implements ServiceRotator { private readonly apiBaseUrl: string = 'https://api.github.com'; private readonly authBaseUrl: string = 'https://github.com'; private readonly axiosInstance: AxiosInstance; private readonly logger: Logger; private readonly oauth2Config?: GitHubOAuth2Config; private readonly retryOptions: RetryOptions; private static readonly OAUTH_HEADERS = { Accept: 'application/json', 'Content-Type': 'application/json' }; private static readonly authHeader = (token: string) => ({ Authorization: `token ${token}` }); /** * Creates a new GitHubRotator instance * @param oauth2Config Optional OAuth2 configuration for OAuth flow * @param retryOptions Optional retry configuration * @param axiosConfig Optional Axios configuration */ constructor( oauth2Config?: GitHubOAuth2Config, retryOptions?: Partial<RetryOptions>, axiosConfig?: AxiosRequestConfig ) { this.oauth2Config = oauth2Config; // Initialize retry options with defaults this.retryOptions = { maxRetries: retryOptions?.maxRetries ?? 3, initialDelayMs: retryOptions?.initialDelayMs ?? 1000, maxDelayMs: retryOptions?.maxDelayMs ?? 30000, backoffFactor: retryOptions?.backoffFactor ?? 2, retryStatusCodes: retryOptions?.retryStatusCodes ?? [429, 500, 502, 503, 504] }; // Initialize axios instance with default config this.axiosInstance = axios.create({ baseURL: this.apiBaseUrl, timeout: 10000, headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TokenGuardian-Rotator' }, ...axiosConfig }); // Add response interceptor for rate limit handling this.axiosInstance.interceptors.response.use( (response) => { // Extract and store rate limit info for logging this.parseRateLimitHeaders(response); return response; }, (error) => { if (this.isRateLimited(error)) { this.logger.warn('GitHub API rate limit exceeded', { rateLimitReset: error.response?.headers['x-ratelimit-reset'] }); } return Promise.reject(error); } ); // Initialize logger this.logger = createLogger({ level: 'info', format: format.combine( format.timestamp(), format.json() ), defaultMeta: { service: 'github-rotator' }, transports: [ new transports.Console({ format: format.combine( format.colorize(), format.simple() ) }) ] }); } /** * Parses rate limit information from GitHub API response headers * @param response Axios response object * @returns Rate limit information or undefined if not present */ private parseRateLimitHeaders(response: AxiosResponse): RateLimitInfo | undefined { const headers = response.headers; if ( headers['x-ratelimit-limit'] && headers['x-ratelimit-remaining'] && headers['x-ratelimit-reset'] ) { const rateLimitInfo: RateLimitInfo = { limit: parseInt(headers['x-ratelimit-limit'] as string, 10), remaining: parseInt(headers['x-ratelimit-remaining'] as string, 10), reset: parseInt(headers['x-ratelimit-reset'] as string, 10), used: parseInt(headers['x-ratelimit-used'] as string, 10) || 0, resource: headers['x-ratelimit-resource'] as string || 'core' }; // Log if we're getting close to the limit (less than 10% remaining) if (rateLimitInfo.remaining < rateLimitInfo.limit * 0.1) { this.logger.warn('GitHub API rate limit getting low', { remaining: rateLimitInfo.remaining, limit: rateLimitInfo.limit, resetAt: new Date(rateLimitInfo.reset * 1000).toISOString() }); } return rateLimitInfo; } return undefined; } /** * Checks if an error is due to GitHub API rate limiting * @param error Axios error object * @returns True if rate limited, false otherwise */ private isRateLimited(error: AxiosError): boolean { return !!( error.response && error.response.status === 429 && error.response.headers && error.response.headers['x-ratelimit-remaining'] === '0' ); } /** * Calculates backoff delay for retries using exponential backoff algorithm * @param retryCount Current retry count * @param error Error that triggered the retry * @returns Delay in milliseconds before next retry */ private calculateBackoffDelay(retryCount: number, error?: AxiosError): number { let delay = this.retryOptions.initialDelayMs * Math.pow(this.retryOptions.backoffFactor, retryCount); // Add jitter to prevent thundering herd problem (±10%) const jitter = delay * 0.1 * (Math.random() * 2 - 1); delay += jitter; // If rate limited, use the reset time from headers if available if (error?.response?.headers && this.isRateLimited(error)) { const resetTime = parseInt(error.response.headers['x-ratelimit-reset'] as string, 10); if (!isNaN(resetTime)) { const resetDelayMs = (resetTime * 1000) - Date.now() + 1000; // Add 1 second buffer if (resetDelayMs > 0) { delay = Math.min(resetDelayMs, this.retryOptions.maxDelayMs); } } } return Math.min(delay, this.retryOptions.maxDelayMs); } /** * Makes an API request with automatic retry and backoff * @param config Axios request configuration * @returns Promise with axios response */ private async makeRequestWithRetry<T = unknown>( config: AxiosRequestConfig ): Promise<AxiosResponse<T>> { let retryCount = 0; const executeRequest = async (): Promise<AxiosResponse<T>> => { try { return await this.axiosInstance.request<T>(config); } catch (error) { const axiosError = error as AxiosError; // Determine if we should retry const shouldRetry = retryCount < this.retryOptions.maxRetries && ( this.isRateLimited(axiosError) || (axiosError.response && this.retryOptions.retryStatusCodes.includes(axiosError.response.status)) ); if (shouldRetry) { retryCount++; const delay = this.calculateBackoffDelay(retryCount, axiosError); this.logger.info(`Retrying request (${retryCount}/${this.retryOptions.maxRetries})`, { delay, url: config.url, status: axiosError.response?.status, method: config.method }); await new Promise(resolve => setTimeout(resolve, delay)); return executeRequest(); } // If we shouldn't retry, rethrow the error throw error; } }; return executeRequest(); } /** * Retrieves user info with the given token * @param token GitHub token to validate * @returns User information and scopes */ private async getUserInfo(token: string): Promise<{ username: string; tokenScopes: string[] }> { try { const response = await this.makeRequestWithRetry<{ login: string }>({ url: '/user', method: 'GET', headers: GitHubRotator.authHeader(token) }); const scopeHeader = response.headers['x-oauth-scopes'] as string; const tokenScopes = scopeHeader ? scopeHeader.split(', ') : []; const username = response.data.login; return { username, tokenScopes }; } catch (error) { const axiosError = error as AxiosError; if (axiosError.response?.status === 401) { throw new Error('Token is invalid or expired'); } throw error; } } /** * Exchanges an authorization code for an OAuth2 token * @param code Authorization code from OAuth flow * @returns GitHub token information */ public async exchangeCodeForToken(code: string): Promise<GitHubToken> { if (!this.oauth2Config) { throw new Error('OAuth2 configuration is required for this operation'); } try { const tokenEndpoint = this.oauth2Config.tokenEndpoint || `${this.authBaseUrl}/login/oauth/access_token`; type OAuthTokenResponse = { access_token: string; refresh_token?: string; expires_in?: number; token_type?: string; scope?: string; }; const response = await this.makeRequestWithRetry<OAuthTokenResponse>({ url: tokenEndpoint, method: 'POST', headers: GitHubRotator.OAUTH_HEADERS, data: { client_id: this.oauth2Config.clientId, client_secret: this.oauth2Config.clientSecret, code, redirect_uri: this.oauth2Config.redirectUri } }); const data = response.data; if (!data.access_token) { throw new Error('Failed to obtain access token'); } // Calculate expiration date if expires_in is provided let expiresAt: Date | undefined; if (data.expires_in) { expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in); } const token: GitHubToken = { accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, expiresAt, tokenType: data.token_type || 'bearer', scope: data.scope ? data.scope.split(',') : [] }; this.logger.info('Successfully exchanged code for token', { scopes: token.scope, expiresIn: token.expiresIn }); return token; } catch (error) { this.logger.error('Failed to exchange code for token', { error }); throw new Error(`OAuth2 token exchange failed: ${this.formatError(error)}`); } } /** * Refreshes an OAuth2 token using a refresh token * @param refreshToken Refresh token to use * @returns New GitHub token information */ public async refreshOAuth2Token(refreshToken: string): Promise<GitHubToken> { if (!this.oauth2Config) { throw new Error('OAuth2 configuration is required for this operation'); } try { const tokenEndpoint = this.oauth2Config.tokenEndpoint || `${this.authBaseUrl}/login/oauth/access_token`; type OAuthTokenResponse = { access_token: string; refresh_token?: string; expires_in?: number; token_type?: string; scope?: string; }; const response = await this.makeRequestWithRetry<OAuthTokenResponse>({ url: tokenEndpoint, method: 'POST', headers: GitHubRotator.OAUTH_HEADERS, data: { client_id: this.oauth2Config.clientId, client_secret: this.oauth2Config.clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' } }); const data = response.data; if (!data.access_token) { throw new Error('Failed to refresh access token'); } // Calculate expiration date if expires_in is provided let expiresAt: Date | undefined; if (data.expires_in) { expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in); } const token: GitHubToken = { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken, // Use new refresh token or keep old one expiresIn: data.expires_in, expiresAt, tokenType: data.token_type || 'bearer', scope: data.scope ? data.scope.split(',') : [] }; this.logger.info('Successfully refreshed OAuth2 token', { scopes: token.scope, expiresIn: token.expiresIn }); return token; } catch (error) { this.logger.error('Failed to refresh OAuth2 token', { error }); throw new Error(`OAuth2 token refresh failed: ${this.formatError(error)}`); } } /** * Formats error objects into consistent error messages * @param error Any error object to format * @returns Formatted error message string */ private formatError(error: unknown): string { if (axios.isAxiosError(error)) { if (error.response) { const status = error.response.status; const message = error.response.data?.message || 'Unknown error'; const details = error.response.data?.errors ? ` (Details: ${JSON.stringify(error.response.data.errors)})` : ''; return `${status} - ${message}${details}`; } else if (error.request) { return 'No response received from GitHub API'; } else { return `Request setup error: ${error.message}`; } } else if (error instanceof Error) { return error.message; } else { return String(error); } } /** * Validates if a token is still valid and returns its associated information * @param token GitHub token to validate * @returns Validation result with token information */ public async validateToken(token: string): Promise<{ valid: boolean; scopes: string[]; username?: string; rateLimitInfo?: RateLimitInfo; error?: string; }> { try { const { username, tokenScopes } = await this.getUserInfo(token); const response = await this.makeRequestWithRetry({ url: '/rate_limit', method: 'GET', headers: { Authorization: `token ${token}` } }); const rateLimitInfo = this.parseRateLimitHeaders(response); this.logger.info('Token validation successful', { username, scopes: tokenScopes, valid: true }); return { valid: true, scopes: tokenScopes, username, rateLimitInfo }; } catch (error) { const formattedError = this.formatError(error); this.logger.warn('Token validation failed', { error: formattedError }); return { valid: false, scopes: [], error: formattedError }; } } /** * Checks if a token is expired based on its expiration date * @param token GitHub token to check * @returns True if token is expired or about to expire (within 5 minutes) */ public isTokenExpired(token: GitHubToken): boolean { if (token.expiresAt) { return new Date() > token.expiresAt; } return false; } /** * Creates an authorization URL for OAuth2 flow * @param state Random state string for CSRF protection * @returns Complete authorization URL */ public getAuthorizationUrl(state: string): string { if (!this.oauth2Config) { throw new Error('OAuth2 configuration is required for this operation'); } const authEndpoint = this.oauth2Config.authorizationEndpoint || `${this.authBaseUrl}/login/oauth/authorize`; const params = new URLSearchParams({ client_id: this.oauth2Config.clientId, redirect_uri: this.oauth2Config.redirectUri, scope: this.oauth2Config.scope.join(' '), state, response_type: 'code' }); return `${authEndpoint}?${params.toString()}`; } /** * Handles automatic token refresh if needed * @param token Current GitHub token information * @returns Refreshed token or original if refresh not needed/possible */ public async ensureFreshToken(token: GitHubToken): Promise<GitHubToken> { // If token is not expired or doesn't have an expiry date, return as is if (!this.isTokenExpired(token)) { return token; } // If we don't have a refresh token, we can't refresh if (!token.refreshToken) { this.logger.warn('Token is expired but no refresh token is available'); return token; } try { this.logger.info('Token is expired, attempting to refresh'); return await this.refreshOAuth2Token(token.refreshToken); } catch (error) { this.logger.error('Failed to refresh token', { error: this.formatError(error) }); // Return original token even though it's expired return token; } } /** * Creates a new personal access token with specified scopes * @param token Current GitHub token * @param note Description for the new token * @param scopes Required scopes for the new token * @returns New token information */ public async createPersonalAccessToken( token: string, note: string, scopes: string[] ): Promise<{ tokenId: string; token: string }> { try { const response = await this.makeRequestWithRetry<{ id: string; token: string }>({ url: '/authorizations', method: 'POST', headers: GitHubRotator.authHeader(token), data: { scopes, note, fingerprint: `token-guardian-${Date.now()}` } }); this.logger.info('Successfully created new personal access token', { note, scopes }); return { tokenId: response.data.id, token: response.data.token }; } catch (error) { this.logger.error('Failed to create personal access token', { error: this.formatError(error), note, scopes }); throw new Error(`Failed to create personal access token: ${this.formatError(error)}`); } } /** * Deletes a personal access token by its ID * @param token Current GitHub token * @param tokenId ID of the token to delete * @returns True if deletion was successful */ public async deletePersonalAccessToken(token: string, tokenId: string): Promise<boolean> { try { // First validate that the token still exists try { await this.makeRequestWithRetry({ url: `/authorizations/${tokenId}`, method: 'GET', headers: { Authorization: `token ${token}` } }); } catch (checkError) { if (axios.isAxiosError(checkError) && checkError.response?.status === 404) { this.logger.info('Token already deleted or does not exist', { tokenId }); return true; // Consider it a success if token is already gone } // For other errors during check, continue with deletion attempt } await this.makeRequestWithRetry({ url: `/authorizations/${tokenId}`, method: 'DELETE', headers: { Authorization: `token ${token}` } }); this.logger.info('Successfully deleted personal access token', { tokenId }); return true; } catch (error) { this.logger.error('Failed to delete personal access token', { error: this.formatError(error), tokenId }); return false; } } /** * Rotates a GitHub token by creating a new one and deleting the old one * @param tokenName The name/identifier of the token * @param currentToken The current token value * @returns Result of the rotation */ public async rotateToken(tokenName: string, currentToken: string): Promise<RotationResult> { try { // Step 1: Validate the current token and gather its scope information let tokenScopes: string[] = []; let username: string = ''; try { const userInfo = await this.getUserInfo(currentToken); tokenScopes = userInfo.tokenScopes; username = userInfo.username; this.logger.info('Validated existing token for rotation', { tokenName, username, scopes: tokenScopes }); } catch (error) { const formattedError = this.formatError(error); this.logger.error('Failed to validate token for rotation', { tokenName, error: formattedError }); if (axios.isAxiosError(error) && error.response?.status === 401) { return { success: false, message: 'Current GitHub token is invalid or expired', newExpiry: null }; } throw error; // Re-throw for the outer catch block } if (!tokenScopes.length) { this.logger.warn('No scopes available for token rotation', { tokenName }); return { success: false, message: 'Could not determine token scopes for rotation', newExpiry: null }; } // Step 2: Create a new token with the same scopes via GitHub API const note = `TokenGuardian Rotated Token (${new Date().toISOString()})`; this.logger.info('Creating new token with matching scopes', { tokenName, scopes: tokenScopes, note }); let newToken: string; let tokenId: string; try { // Use our optimized method for creating a token const tokenResult = await this.createPersonalAccessToken( currentToken, note, tokenScopes ); newToken = tokenResult.token; tokenId = tokenResult.tokenId; this.logger.info('Successfully created new token', { tokenName, tokenId }); } catch (error) { // This is a critical failure - we couldn't create a new token const formattedError = this.formatError(error); this.logger.error('Failed to create new token during rotation', { tokenName, error: formattedError }); return { success: false, message: `Failed to create new GitHub token: ${formattedError}`, newExpiry: null }; } // If we got this far, we have a new token // Step 3: Verify the new token works try { this.logger.info('Validating new token', { tokenName }); const validationResult = await this.validateToken(newToken); if (!validationResult.valid) { this.logger.error('New token validation failed', { tokenName }); return { success: false, message: 'Created new GitHub token, but validation failed', newExpiry: null }; } this.logger.info('New token validated successfully', { tokenName, scopes: validationResult.scopes }); } catch (error) { const formattedError = this.formatError(error); this.logger.error('Exception during new token validation', { tokenName, error: formattedError }); // We created a token but can't verify it - return it anyway but with a warning return { success: true, message: `GitHub token rotated, but validation failed: ${formattedError}`, newToken, newExpiry: null, warnings: [`Token validation failed: ${formattedError}`] }; } // Step 4: Delete the old token for cleanup if possible let cleanupSuccess = false; try { this.logger.info('Attempting to delete old token for cleanup', { tokenName }); // Check if we have enough permissions to delete tokens if (tokenScopes.includes('delete_repo') || tokenScopes.includes('admin:org')) { // We don't have the ID of the old token, so we need to list all tokens // and find the ones that aren't our new token const response = await this.makeRequestWithRetry<Array<{ note?: string; token_last_eight?: string; id: string }>>({ url: '/authorizations', method: 'GET', headers: GitHubRotator.authHeader(currentToken) }); // Find tokens that match our naming pattern but aren't the new one const oldTokens = response.data.filter(token => token.note && token.note.includes('TokenGuardian') && token.token_last_eight !== newToken.slice(-8) ); if (oldTokens.length > 0) { this.logger.info(`Found ${oldTokens.length} old tokens to clean up`, { tokenName }); // Delete old tokens in parallel for efficiency const deletionResults = await Promise.all( oldTokens.map((t: { id: string }) => this.deletePersonalAccessToken(currentToken, t.id)) ); cleanupSuccess = deletionResults.some(result => result === true); this.logger.info(`Deleted ${deletionResults.filter(Boolean).length}/${oldTokens.length} old tokens`, { tokenName, success: cleanupSuccess }); } else { this.logger.info('No old tokens found for cleanup', { tokenName }); } } else { this.logger.warn('Insufficient permissions to delete old tokens', { tokenName, scopes: tokenScopes }); } } catch (error) { const formattedError = this.formatError(error); this.logger.warn('Failed to clean up old tokens', { tokenName, error: formattedError }); // We don't fail the rotation if cleanup fails } // Step 5: Return successful rotation result // GitHub tokens don't have an expiry by default return { success: true, message: cleanupSuccess ? 'GitHub token rotated successfully with cleanup' : 'GitHub token rotated successfully', newToken, newExpiry: null, warnings: cleanupSuccess ? undefined : ['Old token could not be deleted'] }; } catch (error) { // Catch-all error handler for unexpected issues let errorMessage = 'Unknown error during GitHub token rotation'; if (axios.isAxiosError(error)) { if (error.response) { errorMessage = `GitHub API error: ${error.response.status} - ${error.response.data?.message || 'Unknown error'}`; // Add detailed errors if available if (error.response.data?.errors) { errorMessage += ` (Details: ${JSON.stringify(error.response.data.errors)})`; } // Special handling for specific error codes if (error.response.status === 403 && error.response.data?.message?.includes('rate limit')) { errorMessage = `GitHub API rate limit exceeded. Try again after ${new Date( parseInt(error.response.headers['x-ratelimit-reset'] as string, 10) * 1000 ).toLocaleString()}`; } else if (error.response.status === 422) { errorMessage = `GitHub API validation error: ${error.response.data?.message}`; } } else if (error.request) { errorMessage = 'No response received from GitHub API'; } else { errorMessage = `Error setting up request: ${error.message}`; } } else if (error instanceof Error) { errorMessage = `Error rotating GitHub token: ${error.message}`; } this.logger.error('GitHub token rotation failed', { error: errorMessage, stack: error instanceof Error ? error.stack : undefined }); return { success: false, message: errorMessage, newExpiry: null }; } } }