UNPKG

@teamwork/get-bearer-token

Version:

CLI tool to obtain bearer tokens for Teamwork API using OAuth flow

362 lines (322 loc) 12.8 kB
import express from "express"; import axios from "axios"; import chalk from "chalk"; import open from "open"; /** * Create OAuth authorization URL * @param {import('./types.js').TeamworkCredentials} credentials * @returns {string} */ export function createAuthUrl(credentials) { // Use teamworkHost if available, otherwise build from region/siteName for backwards compatibility let baseUrl; if (credentials.teamworkHost) { baseUrl = `https://${credentials.teamworkHost}/launchpad/login`; } else { // Fallback to old method for backwards compatibility const getTeamworkDomain = (region) => { switch (region) { case "eu": return "eu.teamwork.com"; case "staging": return "staging.teamwork.com"; case "us": default: return "teamwork.com"; } }; const domain = getTeamworkDomain(credentials.region); baseUrl = credentials.siteName ? `https://${credentials.siteName}.${domain}/launchpad/login` : `https://www.${domain}/launchpad/login/`; } const params = new URLSearchParams({ redirect_uri: credentials.redirectUri, client_id: credentials.clientId, state: Math.random().toString(36).substring(2, 15), // Random state for security }); return `${baseUrl}?${params.toString()}`; } /** * Exchange authorization code for access token * @param {string} code * @param {import('./types.js').TeamworkCredentials} credentials * @returns {Promise<import('./types.js').TeamworkTokenResponse>} */ export async function exchangeCodeForToken(code, credentials) { try { // Use region-specific token endpoint const getTokenEndpoint = (region) => { switch (region) { case "eu": return "https://www.eu.teamwork.com/launchpad/v1/token.json"; case "staging": return "https://staging.teamwork.com/launchpad/v1/token.json"; // Try staging launchpad case "us": default: return "https://www.teamwork.com/launchpad/v1/token.json"; } }; const tokenEndpoint = getTokenEndpoint(credentials.region); console.log(chalk.gray(` Using token endpoint: ${tokenEndpoint}`)); const response = await axios.post( tokenEndpoint, { code, client_id: credentials.clientId, client_secret: credentials.clientSecret, redirect_uri: credentials.redirectUri, }, { headers: { "Content-Type": "application/json", }, } ); console.log(chalk.gray(` Token response received`)); console.log(chalk.gray(` Response keys: ${Object.keys(response.data).join(', ')}`)); if (response.data.installation) { console.log(chalk.gray(` Installation keys: ${Object.keys(response.data.installation).join(', ')}`)); } return response.data; } catch (error) { if (error.response) { throw new Error( `Token exchange failed: ${ error.response.data.message || error.response.statusText }` ); } throw new Error(`Network error during token exchange: ${error.message}`); } } /** * Get user information using the access token * @param {string} accessToken * @returns {Promise<import('./types.js').TeamworkUserInfo>} */ export async function getUserInfo(accessToken, credentials) { try { // Use region-specific userinfo endpoint const getUserInfoEndpoint = (region) => { switch (region) { case "eu": return "https://www.eu.teamwork.com/launchpad/v1/userinfo.json"; case "staging": return "https://staging.teamwork.com/launchpad/v1/userinfo.json"; // Try staging launchpad case "us": default: return "https://www.teamwork.com/launchpad/v1/userinfo.json"; } }; const userInfoEndpoint = getUserInfoEndpoint(credentials.region); const response = await axios.get(userInfoEndpoint, { headers: { Authorization: `Bearer ${accessToken}`, }, }); return response.data; } catch (error) { if (error.response) { throw new Error( `Failed to get user info: ${ error.response.data.message || error.response.statusText }` ); } throw new Error(`Network error getting user info: ${error.message}`); } } /** * Start a temporary HTTP server to handle the OAuth callback * @param {number} port * @param {import('./types.js').TeamworkCredentials} credentials * @returns {Promise<import('./types.js').AuthResult>} */ export function startCallbackServer(port, credentials) { return new Promise((resolve, reject) => { const app = express(); let server; // Timeout after 5 minutes const timeout = setTimeout(() => { if (server) { server.close(); } reject(new Error("Authentication timeout after 5 minutes")); }, 5 * 60 * 1000); app.get("/callback", async (req, res) => { try { const { code, error, error_description } = req.query; if (error) { const errorMsg = error_description || error; res.send(` <html> <body style="font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px;"> <h1 style="color: #e74c3c;">❌ Authentication Failed</h1> <p style="color: #7f8c8d;">Error: ${errorMsg}</p> <p>You can close this window and try again.</p> </body> </html> `); clearTimeout(timeout); server.close(); reject(new Error(`Authentication error: ${errorMsg}`)); return; } if (!code) { res.send(` <html> <body style="font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px;"> <h1 style="color: #e74c3c;">❌ No Authorization Code</h1> <p style="color: #7f8c8d;">No authorization code received from Teamwork.</p> <p>You can close this window and try again.</p> </body> </html> `); clearTimeout(timeout); server.close(); reject(new Error("No authorization code received")); return; } console.log( chalk.yellow("📝 Exchanging authorization code for access token...") ); const tokenResponse = await exchangeCodeForToken(code, credentials); const userInfo = await getUserInfo( tokenResponse.access_token, credentials ); // Handle different response structures between environments const installation = tokenResponse.installation || {}; const apiEndpoint = installation.apiEndPoint || installation.apiEndpoint || credentials.teamworkHost ? `https://${credentials.teamworkHost}` : 'Unknown'; const result = { accessToken: tokenResponse.access_token, apiEndpoint: apiEndpoint, userInfo, installation: installation, }; res.send(` <html> <head> <style> body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } .token-container { background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #e9ecef; } .token-field { display: flex; gap: 10px; align-items: center; margin-top: 15px; } .token-input { flex: 1; padding: 10px; font-family: monospace; font-size: 12px; border: 1px solid #ddd; border-radius: 4px; background: #fff; } .copy-btn { padding: 10px 15px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; } .copy-btn:hover { background: #0056b3; } .copy-btn:active { background: #004085; } .success-msg { color: #28a745; font-size: 12px; margin-top: 5px; opacity: 0; transition: opacity 0.3s; } .success-msg.show { opacity: 1; } </style> </head> <body> <h1 style="color: #27ae60;">✅ Authentication Successful!</h1> <div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0;"> <h3>Welcome, ${userInfo.given_name} ${userInfo.family_name}!</h3> <p><strong>Email:</strong> ${userInfo.email}</p> <p><strong>Company:</strong> ${installation.company?.name || 'Unknown'}</p> <p><strong>API Endpoint:</strong> ${apiEndpoint}</p> </div> <div class="token-container"> <h3 style="margin-top: 0; color: #495057;">🔑 Your Bearer Token:</h3> <div class="token-field"> <input type="text" class="token-input" value="${tokenResponse.access_token}" readonly id="tokenField"> <button class="copy-btn" onclick="copyToken()">Copy</button> </div> <div class="success-msg" id="copySuccess">✅ Token copied to clipboard!</div> </div> <p style="color: #27ae60; font-weight: bold;">Your bearer token has been generated successfully!</p> <p style="color: #7f8c8d;">You can close this window and return to your terminal to see the token details.</p> <script> async function copyToken() { try { const tokenField = document.getElementById('tokenField'); const successMsg = document.getElementById('copySuccess'); await navigator.clipboard.writeText(tokenField.value); successMsg.classList.add('show'); setTimeout(() => { successMsg.classList.remove('show'); }, 2000); } catch (err) { // Fallback for older browsers const tokenField = document.getElementById('tokenField'); tokenField.select(); document.execCommand('copy'); const successMsg = document.getElementById('copySuccess'); successMsg.classList.add('show'); setTimeout(() => { successMsg.classList.remove('show'); }, 2000); } } // Auto-select token on click document.getElementById('tokenField').onclick = function() { this.select(); }; </script> </body> </html> `); clearTimeout(timeout); server.close(); resolve(result); } catch (error) { res.send(` <html> <body style="font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px;"> <h1 style="color: #e74c3c;">❌ Authentication Failed</h1> <p style="color: #7f8c8d;">Error: ${error.message}</p> <p>You can close this window and try again.</p> </body> </html> `); clearTimeout(timeout); server.close(); reject(error); } }); server = app.listen(port, () => { console.log(chalk.green(`🚀 Callback server started on port ${port}`)); }); server.on("error", (error) => { clearTimeout(timeout); if (error.code === "EADDRINUSE") { reject( new Error( `Port ${port} is already in use. Please set a different PORT in your .env file.` ) ); } else { reject(error); } }); }); } /** * Perform the complete OAuth flow * @param {import('./types.js').TeamworkCredentials} credentials * @param {number} port * @returns {Promise<import('./types.js').AuthResult>} */ export async function performOAuthFlow(credentials, port) { console.log(chalk.blue("🔐 Starting Teamwork OAuth authentication...\n")); // Start the callback server const authPromise = startCallbackServer(port, credentials); // Create and open the authorization URL const authUrl = createAuthUrl(credentials); console.log(chalk.cyan("🌐 Opening browser for authentication...")); console.log( chalk.gray(`If the browser doesn't open automatically, visit: ${authUrl}\n`) ); try { await open(authUrl); } catch (error) { console.log( chalk.yellow( "⚠️ Could not automatically open browser. Please manually visit the URL above." ) ); } return authPromise; }