@teamwork/get-bearer-token
Version:
CLI tool to obtain bearer tokens for Teamwork API using OAuth flow
362 lines (322 loc) • 12.8 kB
JavaScript
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;
}