adpa-enterprise-framework-automation
Version:
Modular, standards-compliant Node.js/TypeScript automation framework for enterprise requirements, project, and data management. Provides CLI and API for BABOK v3, PMBOK 7th Edition, and DMBOK 2.0 (in progress). Production-ready Express.js API with TypeSpe
353 lines ⢠13.6 kB
JavaScript
/**
* Confluence OAuth 2.0 (3LO) Authentication Handler
* Part of ADPA (Automated Document Processing Assistant) v2.1.3
*
* Handles OAuth 2.0 authentication flow for Confluence Cloud
* Features:
* - Authorization URL generation
* - Token exchange and refresh
* - Secure token storage
* - Automatic token refresh
*/
import axios from 'axios';
import express from 'express';
import { createServer } from 'http';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
/**
* OAuth 2.0 Authentication Handler for Confluence
*/
export class ConfluenceOAuth2 {
config;
tokens = null;
tokenFilePath;
constructor(config) {
this.config = config;
this.tokenFilePath = join(process.cwd(), '.confluence-tokens.json');
this.loadStoredTokens();
}
/**
* Generate authorization URL for OAuth 2.0 flow
*/ getAuthorizationUrl() {
const state = this.generateState();
const params = new URLSearchParams({
audience: 'api.atlassian.com',
client_id: this.config.clientId,
scope: this.config.scopes.join(' '),
redirect_uri: this.config.redirectUri,
state: state,
response_type: 'code',
prompt: 'consent'
});
const authUrl = `https://auth.atlassian.com/authorize?${params.toString()}`;
// Log the generated URL for debugging
console.log('\nš OAuth 2.0 Authorization URL Generated:');
console.log('ā'.repeat(60));
console.log(`URL: ${authUrl}`);
console.log(`Client ID: ${this.config.clientId}`);
console.log(`Redirect URI: ${this.config.redirectUri}`);
console.log(`Scopes: ${this.config.scopes.join(', ')}`);
console.log(`State: ${state}`);
console.log('ā'.repeat(60));
console.log('š Next Steps:');
console.log('1. Click the URL above or copy it to your browser');
console.log('2. Log in to your Atlassian account');
console.log('3. Grant permissions to the application');
console.log('4. You will be redirected back to the callback URL');
console.log('ā'.repeat(60));
return authUrl;
}
/**
* Start OAuth 2.0 authorization flow with local server
*/
async startAuthorizationFlow() {
return new Promise((resolve, reject) => {
const app = express();
const server = createServer(app);
// Handle the OAuth callback
app.get('/callback', async (req, res) => {
try {
const { code, state } = req.query;
if (!code) {
throw new Error('Authorization code not received');
}
// Exchange code for tokens
const tokens = await this.exchangeCodeForTokens(code);
res.send(`
<html>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1 style="color: green;">ā
Authorization Successful!</h1>
<p>ADPA now has access to your Confluence. You can close this window.</p>
<p>Return to your terminal to continue.</p>
</body>
</html>
`);
server.close();
resolve(tokens);
}
catch (error) {
res.send(`
<html>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1 style="color: red;">ā Authorization Failed</h1>
<p>Error: ${error.message}</p>
<p>Please try again or check your configuration.</p>
</body>
</html>
`);
server.close();
reject(error);
}
});
// Start server
const port = new URL(this.config.redirectUri).port || '3000';
server.listen(parseInt(port), () => {
const authUrl = this.getAuthorizationUrl();
console.log(`š Please open this URL in your browser to authorize ADPA:`);
console.log(`\n${authUrl}\n`);
console.log(`ā³ Waiting for authorization...`);
// Auto-open browser (optional)
try {
const { exec } = require('child_process');
exec(`start "" "${authUrl}"`); // Windows
}
catch (e) {
// Ignore if can't auto-open
}
});
// Timeout after 5 minutes
setTimeout(() => {
server.close();
reject(new Error('Authorization timeout - please try again'));
}, 5 * 60 * 1000);
});
}
/**
* Exchange authorization code for access tokens
*/
async exchangeCodeForTokens(code) {
try {
const response = await axios.post('https://auth.atlassian.com/oauth/token', {
grant_type: 'authorization_code',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code: code,
redirect_uri: this.config.redirectUri
}, {
headers: {
'Content-Type': 'application/json'
}
});
const tokens = response.data;
tokens.expires_at = Date.now() + (tokens.expires_in * 1000);
this.tokens = tokens;
this.saveTokens();
return tokens;
}
catch (error) {
throw new Error(`Token exchange failed: ${error.response?.data?.error_description || error.message}`);
}
}
/**
* Get valid access token (refresh if needed)
*/
async getValidAccessToken() {
if (!this.tokens) {
throw new Error('No tokens available - please authorize first');
}
// Check if token is expired (refresh 5 minutes before expiry)
const expiresAt = this.tokens.expires_at || 0;
const refreshThreshold = Date.now() + (5 * 60 * 1000); // 5 minutes
if (expiresAt < refreshThreshold) {
await this.refreshTokens();
}
return this.tokens.access_token;
} /**
* Refresh access tokens using rotating refresh token
* Follows Atlassian's rotating refresh token implementation
*/
async refreshTokens() {
if (!this.tokens?.refresh_token) {
throw new Error('No refresh token available - please re-authorize');
}
try {
console.log('š Refreshing access token with rotating refresh token...');
const response = await fetch('https://auth.atlassian.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
grant_type: 'refresh_token',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
refresh_token: this.tokens.refresh_token
})
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorData}`);
}
const json = await response.json();
// Update access token
this.tokens.access_token = json.access_token;
this.tokens.token_type = json.token_type || 'Bearer';
this.tokens.expires_in = json.expires_in;
this.tokens.expires_at = Date.now() + (json.expires_in * 1000);
this.tokens.scope = json.scope;
// CRITICAL: Replace old refresh token with new rotating refresh token
if (json.refresh_token) {
console.log('š Updating to new rotating refresh token');
this.tokens.refresh_token = json.refresh_token;
}
// Save updated tokens with new rotating refresh token
this.saveTokens();
console.log('ā
Access token refreshed successfully');
console.log(' New token expires in:', json.expires_in, 'seconds');
console.log(' New refresh token received:', !!json.refresh_token);
return this.tokens;
}
catch (error) {
// If refresh fails, clear tokens and require re-authorization
this.clearTokens();
throw new Error(`Token refresh failed: ${error.response?.data?.error_description || error.message}`);
}
}
/**
* Get accessible Confluence resources
*/
async getAccessibleResources() {
const accessToken = await this.getValidAccessToken();
try {
const response = await axios.get('https://api.atlassian.com/oauth/token/accessible-resources', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json'
}
});
return response.data;
}
catch (error) {
throw new Error(`Failed to get accessible resources: ${error.response?.data?.message || error.message}`);
}
}
/**
* Find Cloud ID for a specific site URL
*/
async findCloudId(siteUrl) {
const resources = await this.getAccessibleResources();
const resource = resources.find(r => r.url === siteUrl);
return resource?.id || null;
}
/**
* Check if currently authorized
*/
isAuthorized() {
return this.tokens !== null && this.tokens.access_token !== undefined;
}
/**
* Check if tokens are expired
*/
isTokenExpired() {
if (!this.tokens?.expires_at)
return true;
return Date.now() >= this.tokens.expires_at;
}
/**
* Get current token status
*/
getTokenStatus() {
return {
authorized: this.isAuthorized(),
expired: this.isTokenExpired(),
expiresIn: this.tokens?.expires_at ? Math.max(0, this.tokens.expires_at - Date.now()) : null,
scopes: this.tokens?.scope?.split(' ') || []
};
}
/**
* Revoke tokens and clear storage
*/
async revokeAuthorization() {
if (this.tokens?.access_token) {
try {
await axios.post('https://auth.atlassian.com/oauth/token/revoke', {
token: this.tokens.access_token
}, {
headers: {
'Authorization': `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64')}`,
'Content-Type': 'application/json'
}
});
}
catch (error) {
// Ignore revoke errors - clear tokens anyway
}
}
this.clearTokens();
}
/**
* Load stored tokens from disk
*/
loadStoredTokens() {
try {
if (existsSync(this.tokenFilePath)) {
const tokenData = readFileSync(this.tokenFilePath, 'utf-8');
this.tokens = JSON.parse(tokenData);
}
}
catch (error) {
// Ignore loading errors - will require re-authorization
this.tokens = null;
}
}
/**
* Save tokens to disk
*/
saveTokens() {
try {
if (this.tokens) {
writeFileSync(this.tokenFilePath, JSON.stringify(this.tokens, null, 2), 'utf-8');
}
}
catch (error) {
console.warn('Failed to save tokens to disk:', error);
}
}
/**
* Clear tokens from memory and disk
*/
clearTokens() {
this.tokens = null;
try {
if (existsSync(this.tokenFilePath)) {
require('fs').unlinkSync(this.tokenFilePath);
}
}
catch (error) {
// Ignore file deletion errors
}
}
/**
* Generate random state parameter for OAuth security
*/
generateState() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
}
/**
* Factory function to create OAuth 2.0 handler from environment variables
*/
export function createConfluenceOAuth2() {
const config = {
clientId: process.env.CONFLUENCE_CLIENT_ID || '',
clientSecret: process.env.CONFLUENCE_CLIENT_SECRET || '',
redirectUri: process.env.CONFLUENCE_REDIRECT_URI || 'http://localhost:3000/callback',
scopes: (process.env.CONFLUENCE_SCOPES || 'read:confluence-content.all,write:confluence-content,read:confluence-space.summary,read:confluence-user').split(','),
baseUrl: process.env.CONFLUENCE_BASE_URL
};
if (!config.clientId || !config.clientSecret) {
throw new Error('OAuth 2.0 configuration incomplete. Please set CONFLUENCE_CLIENT_ID and CONFLUENCE_CLIENT_SECRET environment variables.');
}
return new ConfluenceOAuth2(config);
}
//# sourceMappingURL=ConfluenceOAuth2.js.map