feat: implement initial auth
This commit is contained in:
@@ -6,8 +6,7 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import ora from 'ora';
|
||||
import { exec } from 'child_process';
|
||||
import ora, { type Ora } from 'ora';
|
||||
import {
|
||||
AuthManager,
|
||||
AuthenticationError,
|
||||
@@ -212,7 +211,9 @@ export class AuthCommand extends Command {
|
||||
};
|
||||
} else {
|
||||
console.log(chalk.yellow('✗ Not authenticated'));
|
||||
console.log(chalk.gray('\n Run "task-master auth login" to authenticate'));
|
||||
console.log(
|
||||
chalk.gray('\n Run "task-master auth login" to authenticate')
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
@@ -354,81 +355,52 @@ export class AuthCommand extends Command {
|
||||
* Authenticate with browser using OAuth 2.0 with PKCE
|
||||
*/
|
||||
private async authenticateWithBrowser(): Promise<AuthCredentials> {
|
||||
const spinner = ora('Starting authentication server...').start();
|
||||
let authSpinner: Ora | null = null;
|
||||
|
||||
try {
|
||||
// Start OAuth flow with PKCE (this starts the local server)
|
||||
const authPromise = this.authManager.startOAuthFlow();
|
||||
// Use AuthManager's new unified OAuth flow method with callbacks
|
||||
const credentials = await this.authManager.authenticateWithOAuth({
|
||||
openBrowser: true,
|
||||
timeout: 5 * 60 * 1000, // 5 minutes
|
||||
|
||||
// Wait a moment for server to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
// Callback when auth URL is ready
|
||||
onAuthUrl: (authUrl) => {
|
||||
// Display authentication instructions
|
||||
console.log(chalk.blue.bold('\n🔐 Browser Authentication\n'));
|
||||
console.log(chalk.white(' Opening your browser to authenticate...'));
|
||||
console.log(chalk.gray(" If the browser doesn't open, visit:"));
|
||||
console.log(chalk.cyan.underline(` ${authUrl}\n`));
|
||||
},
|
||||
|
||||
// Get the authorization URL
|
||||
const authUrl = this.authManager.getAuthorizationUrl();
|
||||
// Callback when waiting for authentication
|
||||
onWaitingForAuth: () => {
|
||||
authSpinner = ora({
|
||||
text: 'Waiting for authentication...',
|
||||
spinner: 'dots'
|
||||
}).start();
|
||||
},
|
||||
|
||||
if (!authUrl) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to generate authorization URL',
|
||||
'URL_GENERATION_FAILED'
|
||||
);
|
||||
}
|
||||
// Callback on success
|
||||
onSuccess: () => {
|
||||
if (authSpinner) {
|
||||
authSpinner.succeed('Authentication successful!');
|
||||
}
|
||||
},
|
||||
|
||||
spinner.stop();
|
||||
// Callback on error
|
||||
onError: () => {
|
||||
if (authSpinner) {
|
||||
authSpinner.fail('Authentication failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Display authentication instructions
|
||||
console.log(chalk.blue.bold('\n🔐 Browser Authentication\n'));
|
||||
console.log(chalk.white(' Opening your browser to authenticate...'));
|
||||
console.log(chalk.gray(" If the browser doesn't open, visit:"));
|
||||
console.log(chalk.cyan.underline(` ${authUrl}\n`));
|
||||
|
||||
// Open browser
|
||||
this.openBrowser(authUrl);
|
||||
|
||||
// Wait for authentication with spinner
|
||||
const authSpinner = ora({
|
||||
text: 'Waiting for authentication...',
|
||||
spinner: 'dots'
|
||||
}).start();
|
||||
|
||||
try {
|
||||
const credentials = await authPromise;
|
||||
authSpinner.succeed('Authentication successful!');
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
authSpinner.fail('Authentication failed');
|
||||
throw error;
|
||||
}
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (spinner.isSpinning) {
|
||||
spinner.fail('Authentication failed');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open browser with the given URL
|
||||
*/
|
||||
private openBrowser(url: string): void {
|
||||
const platform = process.platform;
|
||||
let command: string;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
command = `open "${url}"`;
|
||||
} else if (platform === 'win32') {
|
||||
command = `start "${url}"`;
|
||||
} else {
|
||||
command = `xdg-open "${url}"`;
|
||||
}
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
// Silently fail - user can still manually open the URL
|
||||
console.log(chalk.gray('\n (Could not automatically open browser)'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication errors
|
||||
*/
|
||||
|
||||
39
package-lock.json
generated
39
package-lock.json
generated
@@ -95,12 +95,12 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tm/core": "*",
|
||||
"@tm/logger": "*",
|
||||
"boxen": "^7.1.1",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^12.1.0",
|
||||
"inquirer": "^9.2.10",
|
||||
"open": "^10.2.0",
|
||||
"ora": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -9059,10 +9059,6 @@
|
||||
"resolved": "packages/tm-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@tm/logger": {
|
||||
"resolved": "packages/logger",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.2.7",
|
||||
"license": "MIT",
|
||||
@@ -11092,7 +11088,6 @@
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
@@ -12485,7 +12480,6 @@
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.2.1",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
@@ -12500,7 +12494,6 @@
|
||||
},
|
||||
"node_modules/default-browser-id": {
|
||||
"version": "5.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -12547,7 +12540,6 @@
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -15902,7 +15894,6 @@
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "3.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
@@ -16016,7 +16007,6 @@
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^3.0.0"
|
||||
@@ -16340,7 +16330,6 @@
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-inside-container": "^1.0.0"
|
||||
@@ -20634,7 +20623,8 @@
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"devOptional": true,
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.2.1",
|
||||
@@ -22707,7 +22697,6 @@
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.0.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -25911,7 +25900,6 @@
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0"
|
||||
@@ -26161,6 +26149,7 @@
|
||||
"packages/logger": {
|
||||
"name": "@tm/logger",
|
||||
"version": "1.0.0",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0"
|
||||
},
|
||||
@@ -26169,29 +26158,13 @@
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
"packages/logger/node_modules/@types/node": {
|
||||
"version": "20.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
|
||||
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"packages/logger/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/tm-core": {
|
||||
"name": "@tm/core",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tm/logger": "*",
|
||||
"chalk": "^5.3.0",
|
||||
"open": "^10.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"open": "^10.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,74 +1,42 @@
|
||||
/**
|
||||
* Authentication manager for tryhamster.com
|
||||
* Authentication manager for Task Master CLI
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import { getLogger } from '../logger';
|
||||
|
||||
// Auth configuration
|
||||
const AUTH_CONFIG_DIR = path.join(os.homedir(), '.taskmaster');
|
||||
const AUTH_CONFIG_FILE = path.join(AUTH_CONFIG_DIR, 'auth.json');
|
||||
// const API_BASE_URL = process.env.HAMSTER_API_URL || 'https://tryhamster.com/api';
|
||||
const API_BASE_URL = process.env.HAMSTER_API_URL || 'https://localhost:8080';
|
||||
|
||||
export interface AuthCredentials {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
tokenType?: 'standard' | 'api_key';
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export interface AuthOptions {
|
||||
email?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error class
|
||||
*/
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
}
|
||||
}
|
||||
import {
|
||||
AuthCredentials,
|
||||
OAuthFlowOptions,
|
||||
AuthenticationError,
|
||||
AuthConfig
|
||||
} from './types';
|
||||
import { CredentialStore } from './credential-store';
|
||||
import { ApiClient } from './api-client';
|
||||
import { OAuthService } from './oauth-service';
|
||||
|
||||
/**
|
||||
* Authentication manager class
|
||||
*/
|
||||
export class AuthManager {
|
||||
private static instance: AuthManager;
|
||||
private logger = getLogger('AuthManager');
|
||||
private credentialStore: CredentialStore;
|
||||
private apiClient: ApiClient;
|
||||
private oauthService: OAuthService;
|
||||
|
||||
private constructor() {}
|
||||
private constructor(config?: Partial<AuthConfig>) {
|
||||
this.credentialStore = new CredentialStore(config);
|
||||
this.apiClient = new ApiClient(config);
|
||||
this.oauthService = new OAuthService(
|
||||
this.apiClient,
|
||||
this.credentialStore,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): AuthManager {
|
||||
static getInstance(config?: Partial<AuthConfig>): AuthManager {
|
||||
if (!AuthManager.instance) {
|
||||
AuthManager.instance = new AuthManager();
|
||||
AuthManager.instance = new AuthManager(config);
|
||||
}
|
||||
return AuthManager.instance;
|
||||
}
|
||||
@@ -77,407 +45,23 @@ export class AuthManager {
|
||||
* Get stored authentication credentials
|
||||
*/
|
||||
getCredentials(): AuthCredentials | null {
|
||||
try {
|
||||
// Check for environment variable override (useful for CI/CD)
|
||||
// Similar to SUPABASE_ACCESS_TOKEN pattern
|
||||
if (process.env.TASKMASTER_ACCESS_TOKEN) {
|
||||
return {
|
||||
token: process.env.TASKMASTER_ACCESS_TOKEN,
|
||||
userId: process.env.TASKMASTER_USER_ID || 'env-user',
|
||||
email: process.env.TASKMASTER_EMAIL,
|
||||
tokenType: 'api_key',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
if (!fs.existsSync(AUTH_CONFIG_FILE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authData = JSON.parse(
|
||||
fs.readFileSync(AUTH_CONFIG_FILE, 'utf-8')
|
||||
) as AuthCredentials;
|
||||
|
||||
// Check if token is expired
|
||||
if (authData.expiresAt && new Date(authData.expiresAt) < new Date()) {
|
||||
this.logger.warn('Authentication token has expired');
|
||||
return null;
|
||||
}
|
||||
|
||||
return authData;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to read auth credentials: ${(error as Error).message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return this.credentialStore.getCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save authentication credentials
|
||||
* Start OAuth 2.0 Authorization Code Flow with browser handling
|
||||
*/
|
||||
private saveCredentials(authData: AuthCredentials): void {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(AUTH_CONFIG_DIR)) {
|
||||
fs.mkdirSync(AUTH_CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
authData.savedAt = new Date().toISOString();
|
||||
|
||||
// Save credentials
|
||||
fs.writeFileSync(AUTH_CONFIG_FILE, JSON.stringify(authData, null, 2));
|
||||
|
||||
// Set file permissions to read/write for owner only
|
||||
fs.chmodSync(AUTH_CONFIG_FILE, 0o600);
|
||||
} catch (error) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to save auth credentials: ${(error as Error).message}`,
|
||||
'SAVE_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request
|
||||
*/
|
||||
private makeApiRequest(endpoint: string, options: any = {}): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(endpoint, API_BASE_URL);
|
||||
|
||||
const requestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = protocol.request(requestOptions, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
|
||||
if (
|
||||
res.statusCode &&
|
||||
res.statusCode >= 200 &&
|
||||
res.statusCode < 300
|
||||
) {
|
||||
resolve(parsedData);
|
||||
} else {
|
||||
reject(
|
||||
new AuthenticationError(
|
||||
parsedData.message ||
|
||||
`API request failed with status ${res.statusCode}`,
|
||||
parsedData.code || 'API_ERROR'
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(
|
||||
new AuthenticationError(
|
||||
`Failed to parse API response: ${(error as Error).message}`,
|
||||
'PARSE_ERROR'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(
|
||||
new AuthenticationError(
|
||||
`Network error: ${error.message}`,
|
||||
'NETWORK_ERROR'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(JSON.stringify(options.body));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE parameters for OAuth flow
|
||||
*/
|
||||
private generatePKCEParams(): {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
} {
|
||||
// Generate code verifier (43-128 characters)
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
|
||||
// Generate code challenge using SHA256
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
|
||||
// Generate state for CSRF protection
|
||||
const state = crypto.randomBytes(16).toString('base64url');
|
||||
|
||||
return { codeVerifier, codeChallenge, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random available port
|
||||
*/
|
||||
private async getRandomPort(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = (server.address() as any).port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth 2.0 Authorization Code Flow with PKCE
|
||||
*/
|
||||
async startOAuthFlow(): Promise<AuthCredentials> {
|
||||
const { codeVerifier, codeChallenge, state } = this.generatePKCEParams();
|
||||
const port = await this.getRandomPort();
|
||||
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let serverClosed = false;
|
||||
|
||||
// Create local HTTP server for OAuth callback
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://127.0.0.1:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Send response to browser
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
|
||||
if (error) {
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Failed</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
h1 { color: #dc3545; }
|
||||
p { color: #666; margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>❌ Authentication Failed</h1>
|
||||
<p>${errorDescription || error}</p>
|
||||
<p>You can close this window and try again.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
if (!serverClosed) {
|
||||
serverClosed = true;
|
||||
server.close();
|
||||
reject(
|
||||
new AuthenticationError(
|
||||
errorDescription || error || 'Authentication failed',
|
||||
'OAUTH_ERROR'
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify state parameter
|
||||
if (returnedState !== state) {
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Security Error</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
h1 { color: #dc3545; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>⚠️ Security Error</h1>
|
||||
<p>Invalid state parameter. Please try again.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
if (!serverClosed) {
|
||||
serverClosed = true;
|
||||
server.close();
|
||||
reject(
|
||||
new AuthenticationError(
|
||||
'Invalid state parameter',
|
||||
'INVALID_STATE'
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.container { text-align: center; padding: 3rem; background: white; border-radius: 12px; box-shadow: 0 20px 40px rgba(0,0,0,0.1); }
|
||||
h1 { color: #28a745; margin-bottom: 1rem; }
|
||||
p { color: #666; margin-top: 1rem; }
|
||||
.checkmark { width: 80px; height: 80px; margin: 0 auto 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
||||
<circle cx="26" cy="26" r="25" fill="none" stroke="#28a745" stroke-width="2"/>
|
||||
<path fill="none" stroke="#28a745" stroke-width="3" d="M14 27l7 7 16-16"/>
|
||||
</svg>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>You can close this window and return to your terminal.</p>
|
||||
<p style="color: #999; font-size: 0.9rem; margin-top: 2rem;">Task Master CLI</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
try {
|
||||
// Exchange authorization code for tokens
|
||||
const tokens = await this.exchangeCodeForTokens(
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri
|
||||
);
|
||||
|
||||
if (!serverClosed) {
|
||||
serverClosed = true;
|
||||
server.close();
|
||||
resolve(tokens);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!serverClosed) {
|
||||
serverClosed = true;
|
||||
server.close();
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle other paths (favicon, etc.)
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Start server on localhost only
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
// Build authorization URL
|
||||
const authUrl = new URL(`${API_BASE_URL.replace('/api', '')}/auth/cli`);
|
||||
authUrl.searchParams.append('client_id', 'task-master-cli');
|
||||
authUrl.searchParams.append('redirect_uri', redirectUri);
|
||||
authUrl.searchParams.append('response_type', 'code');
|
||||
authUrl.searchParams.append('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.append('code_challenge_method', 'S256');
|
||||
authUrl.searchParams.append('state', state);
|
||||
authUrl.searchParams.append('scope', 'offline_access'); // Request refresh token
|
||||
|
||||
// Store auth URL for browser opening
|
||||
(this as any).authorizationUrl = authUrl.toString();
|
||||
});
|
||||
|
||||
// Set timeout for authentication
|
||||
setTimeout(
|
||||
() => {
|
||||
if (!serverClosed) {
|
||||
serverClosed = true;
|
||||
server.close();
|
||||
reject(
|
||||
new AuthenticationError('Authentication timeout', 'AUTH_TIMEOUT')
|
||||
);
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
); // 5 minute timeout
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens using PKCE
|
||||
*/
|
||||
private async exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
redirectUri: string
|
||||
async authenticateWithOAuth(
|
||||
options: OAuthFlowOptions = {}
|
||||
): Promise<AuthCredentials> {
|
||||
try {
|
||||
const response = (await this.makeApiRequest('/auth/token', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: 'task-master-cli',
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri
|
||||
}
|
||||
})) as AuthResponse;
|
||||
|
||||
// Save authentication data
|
||||
const authData: AuthCredentials = {
|
||||
token: response.token,
|
||||
refreshToken: response.refreshToken,
|
||||
userId: response.userId,
|
||||
email: response.email,
|
||||
expiresAt: response.expiresAt,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.saveCredentials(authData);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to exchange code for tokens: ${(error as Error).message}`,
|
||||
'TOKEN_EXCHANGE_FAILED'
|
||||
);
|
||||
}
|
||||
return this.oauthService.authenticate(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL (for browser opening)
|
||||
*/
|
||||
getAuthorizationUrl(): string | null {
|
||||
return (this as any).authorizationUrl || null;
|
||||
return this.oauthService.getAuthorizationUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,10 +72,7 @@ export class AuthManager {
|
||||
password: string
|
||||
): Promise<AuthCredentials> {
|
||||
try {
|
||||
const response = (await this.makeApiRequest('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { email, password }
|
||||
})) as AuthResponse;
|
||||
const response = await this.apiClient.login(email, password);
|
||||
|
||||
// Save authentication data
|
||||
const authData: AuthCredentials = {
|
||||
@@ -504,8 +85,7 @@ export class AuthManager {
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.saveCredentials(authData);
|
||||
|
||||
this.credentialStore.saveCredentials(authData);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@@ -517,12 +97,7 @@ export class AuthManager {
|
||||
*/
|
||||
async authenticateWithApiKey(apiKey: string): Promise<AuthCredentials> {
|
||||
try {
|
||||
const response = (await this.makeApiRequest('/auth/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
})) as AuthResponse;
|
||||
const response = await this.apiClient.validateApiKey(apiKey);
|
||||
|
||||
// Save authentication data
|
||||
const authData: AuthCredentials = {
|
||||
@@ -534,8 +109,7 @@ export class AuthManager {
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.saveCredentials(authData);
|
||||
|
||||
this.credentialStore.saveCredentials(authData);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@@ -556,12 +130,7 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await this.makeApiRequest('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
refreshToken: authData.refreshToken
|
||||
}
|
||||
})) as AuthResponse;
|
||||
const response = await this.apiClient.refreshToken(authData.refreshToken);
|
||||
|
||||
// Update authentication data
|
||||
const newAuthData: AuthCredentials = {
|
||||
@@ -571,8 +140,7 @@ export class AuthManager {
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.saveCredentials(newAuthData);
|
||||
|
||||
this.credentialStore.saveCredentials(newAuthData);
|
||||
return newAuthData;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@@ -583,29 +151,14 @@ export class AuthManager {
|
||||
* Logout and clear credentials
|
||||
*/
|
||||
logout(): void {
|
||||
try {
|
||||
if (fs.existsSync(AUTH_CONFIG_FILE)) {
|
||||
fs.unlinkSync(AUTH_CONFIG_FILE);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to logout: ${(error as Error).message}`,
|
||||
'LOGOUT_FAILED'
|
||||
);
|
||||
}
|
||||
this.credentialStore.clearCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
// Fast check for environment variable
|
||||
if (process.env.TASKMASTER_ACCESS_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authData = this.getCredentials();
|
||||
return authData !== null;
|
||||
return this.credentialStore.hasValidCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
38
packages/tm-core/src/auth/config.ts
Normal file
38
packages/tm-core/src/auth/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Centralized authentication configuration
|
||||
*/
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { AuthConfig } from './types';
|
||||
|
||||
// Centralized URL configuration - change these for different environments
|
||||
// For production, use: https://tryhamster.com
|
||||
// For local testing, use: http://localhost:8080
|
||||
const BASE_DOMAIN = 'http://localhost:8080'; // 'https://tryhamster.com';
|
||||
|
||||
/**
|
||||
* Default authentication configuration
|
||||
* All URL configuration should be managed here
|
||||
*/
|
||||
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
|
||||
// API endpoint for backend services
|
||||
apiBaseUrl: process.env.HAMSTER_API_URL || `${BASE_DOMAIN}/api`,
|
||||
|
||||
// Web URL for OAuth sign-in page
|
||||
webBaseUrl: process.env.HAMSTER_WEB_URL || BASE_DOMAIN,
|
||||
|
||||
// Configuration directory and file paths
|
||||
configDir: path.join(os.homedir(), '.taskmaster'),
|
||||
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
|
||||
};
|
||||
|
||||
/**
|
||||
* Get merged configuration with optional overrides
|
||||
*/
|
||||
export function getAuthConfig(overrides?: Partial<AuthConfig>): AuthConfig {
|
||||
return {
|
||||
...DEFAULT_AUTH_CONFIG,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
121
packages/tm-core/src/auth/credential-store.ts
Normal file
121
packages/tm-core/src/auth/credential-store.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Credential storage and management
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { AuthCredentials, AuthenticationError, AuthConfig } from './types';
|
||||
import { getAuthConfig } from './config';
|
||||
import { getLogger } from '../logger';
|
||||
|
||||
export class CredentialStore {
|
||||
private logger = getLogger('CredentialStore');
|
||||
private config: AuthConfig;
|
||||
|
||||
constructor(config?: Partial<AuthConfig>) {
|
||||
this.config = getAuthConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication credentials
|
||||
*/
|
||||
getCredentials(): AuthCredentials | null {
|
||||
try {
|
||||
// Check for environment variable override (useful for CI/CD)
|
||||
if (process.env.TASKMASTER_ACCESS_TOKEN) {
|
||||
return {
|
||||
token: process.env.TASKMASTER_ACCESS_TOKEN,
|
||||
userId: process.env.TASKMASTER_USER_ID || 'env-user',
|
||||
email: process.env.TASKMASTER_EMAIL,
|
||||
tokenType: 'api_key',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.config.configFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authData = JSON.parse(
|
||||
fs.readFileSync(this.config.configFile, 'utf-8')
|
||||
) as AuthCredentials;
|
||||
|
||||
// Check if token is expired
|
||||
if (authData.expiresAt && new Date(authData.expiresAt) < new Date()) {
|
||||
this.logger.warn('Authentication token has expired');
|
||||
return null;
|
||||
}
|
||||
|
||||
return authData;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to read auth credentials: ${(error as Error).message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save authentication credentials
|
||||
*/
|
||||
saveCredentials(authData: AuthCredentials): void {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(this.config.configDir)) {
|
||||
fs.mkdirSync(this.config.configDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
authData.savedAt = new Date().toISOString();
|
||||
|
||||
// Save credentials
|
||||
fs.writeFileSync(
|
||||
this.config.configFile,
|
||||
JSON.stringify(authData, null, 2)
|
||||
);
|
||||
|
||||
// Set file permissions to read/write for owner only
|
||||
fs.chmodSync(this.config.configFile, 0o600);
|
||||
} catch (error) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to save auth credentials: ${(error as Error).message}`,
|
||||
'SAVE_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored credentials
|
||||
*/
|
||||
clearCredentials(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.config.configFile)) {
|
||||
fs.unlinkSync(this.config.configFile);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new AuthenticationError(
|
||||
`Failed to clear credentials: ${(error as Error).message}`,
|
||||
'CLEAR_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist and are valid
|
||||
*/
|
||||
hasValidCredentials(): boolean {
|
||||
// Fast check for environment variable
|
||||
if (process.env.TASKMASTER_ACCESS_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentials = this.getCredentials();
|
||||
return credentials !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration
|
||||
*/
|
||||
getConfig(): AuthConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
30
packages/tm-core/src/auth/index.ts
Normal file
30
packages/tm-core/src/auth/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Authentication module exports
|
||||
*/
|
||||
|
||||
export { AuthManager } from './auth-manager';
|
||||
export { CredentialStore } from './credential-store';
|
||||
export { ApiClient } from './api-client';
|
||||
export { OAuthService } from './oauth-service';
|
||||
|
||||
export type {
|
||||
AuthCredentials,
|
||||
AuthOptions,
|
||||
AuthResponse,
|
||||
OAuthFlowOptions,
|
||||
AuthConfig,
|
||||
CliData
|
||||
} from './types';
|
||||
|
||||
export { AuthenticationError } from './types';
|
||||
|
||||
export {
|
||||
getSuccessHtml,
|
||||
getErrorHtml,
|
||||
getSecurityErrorHtml
|
||||
} from './templates';
|
||||
|
||||
export {
|
||||
DEFAULT_AUTH_CONFIG,
|
||||
getAuthConfig
|
||||
} from './config';
|
||||
327
packages/tm-core/src/auth/oauth-service.ts
Normal file
327
packages/tm-core/src/auth/oauth-service.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* OAuth 2.0 Authorization Code Flow service
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import open from 'open';
|
||||
import {
|
||||
AuthCredentials,
|
||||
AuthenticationError,
|
||||
OAuthFlowOptions,
|
||||
AuthConfig,
|
||||
CliData
|
||||
} from './types';
|
||||
import { ApiClient } from './api-client';
|
||||
import { CredentialStore } from './credential-store';
|
||||
import {
|
||||
getSuccessHtml,
|
||||
getErrorHtml,
|
||||
getSecurityErrorHtml
|
||||
} from './templates';
|
||||
import { getAuthConfig } from './config';
|
||||
import { getLogger } from '../logger';
|
||||
import packageJson from '../../../../package.json' with { type: 'json' };
|
||||
|
||||
export class OAuthService {
|
||||
private logger = getLogger('OAuthService');
|
||||
private apiClient: ApiClient;
|
||||
private credentialStore: CredentialStore;
|
||||
private webBaseUrl: string;
|
||||
private authorizationUrl: string | null = null;
|
||||
private originalState: string | null = null;
|
||||
|
||||
constructor(
|
||||
apiClient: ApiClient,
|
||||
credentialStore: CredentialStore,
|
||||
config: Partial<AuthConfig> = {}
|
||||
) {
|
||||
this.apiClient = apiClient;
|
||||
this.credentialStore = credentialStore;
|
||||
const authConfig = getAuthConfig(config);
|
||||
this.webBaseUrl = authConfig.webBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth 2.0 Authorization Code Flow with browser handling
|
||||
*/
|
||||
async authenticate(options: OAuthFlowOptions = {}): Promise<AuthCredentials> {
|
||||
const {
|
||||
openBrowser = true,
|
||||
timeout = 300000, // 5 minutes default
|
||||
onAuthUrl,
|
||||
onWaitingForAuth,
|
||||
onSuccess,
|
||||
onError
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Start the OAuth flow (starts local server)
|
||||
const authPromise = this.startFlow(timeout);
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Get the authorization URL
|
||||
const authUrl = this.getAuthorizationUrl();
|
||||
|
||||
if (!authUrl) {
|
||||
throw new AuthenticationError(
|
||||
'Failed to generate authorization URL',
|
||||
'URL_GENERATION_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
// Notify about the auth URL
|
||||
if (onAuthUrl) {
|
||||
onAuthUrl(authUrl);
|
||||
}
|
||||
|
||||
// Open browser if requested
|
||||
if (openBrowser) {
|
||||
await this.openBrowser(authUrl);
|
||||
}
|
||||
|
||||
// Notify that we're waiting for authentication
|
||||
if (onWaitingForAuth) {
|
||||
onWaitingForAuth();
|
||||
}
|
||||
|
||||
// Wait for authentication to complete
|
||||
const credentials = await authPromise;
|
||||
|
||||
// Notify success
|
||||
if (onSuccess) {
|
||||
onSuccess(credentials);
|
||||
}
|
||||
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
const authError =
|
||||
error instanceof AuthenticationError
|
||||
? error
|
||||
: new AuthenticationError(
|
||||
`OAuth authentication failed: ${(error as Error).message}`,
|
||||
'OAUTH_FAILED'
|
||||
);
|
||||
|
||||
// Notify error
|
||||
if (onError) {
|
||||
onError(authError);
|
||||
}
|
||||
|
||||
throw authError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the OAuth flow (internal implementation)
|
||||
*/
|
||||
private async startFlow(timeout: number = 300000): Promise<AuthCredentials> {
|
||||
const state = this.generateState();
|
||||
const port = await this.getRandomPort();
|
||||
const callbackUrl = `http://localhost:${port}/callback`;
|
||||
|
||||
// Store the original state for verification
|
||||
this.originalState = state;
|
||||
|
||||
// Prepare CLI data object
|
||||
const cliData: CliData = {
|
||||
callback: callbackUrl,
|
||||
state: state,
|
||||
name: 'Task Master CLI',
|
||||
version: this.getCliVersion(),
|
||||
device: os.hostname(),
|
||||
user: os.userInfo().username,
|
||||
platform: os.platform(),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let serverClosed = false;
|
||||
|
||||
// Create local HTTP server for OAuth callback
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://127.0.0.1:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
await this.handleCallback(
|
||||
url,
|
||||
res,
|
||||
server,
|
||||
serverClosed,
|
||||
resolve,
|
||||
reject
|
||||
);
|
||||
serverClosed = true;
|
||||
} else {
|
||||
// Handle other paths (favicon, etc.)
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Start server on localhost only
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
// Build authorization URL for web app sign-in page
|
||||
const authUrl = new URL(`${this.webBaseUrl}/auth/sign-in`);
|
||||
|
||||
// Encode CLI data as base64
|
||||
const cliParam = Buffer.from(JSON.stringify(cliData)).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
// Set the single CLI parameter with all encoded data
|
||||
authUrl.searchParams.append('cli', cliParam);
|
||||
|
||||
// Store auth URL for browser opening
|
||||
this.authorizationUrl = authUrl.toString();
|
||||
|
||||
this.logger.info(
|
||||
`OAuth session started - ${cliData.name} v${cliData.version} on port ${port}`
|
||||
);
|
||||
this.logger.debug('CLI data:', cliData);
|
||||
});
|
||||
|
||||
// Set timeout for authentication
|
||||
setTimeout(() => {
|
||||
if (!serverClosed) {
|
||||
serverClosed = true;
|
||||
server.close();
|
||||
reject(
|
||||
new AuthenticationError('Authentication timeout', 'AUTH_TIMEOUT')
|
||||
);
|
||||
}
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback
|
||||
*/
|
||||
private async handleCallback(
|
||||
url: URL,
|
||||
res: http.ServerResponse,
|
||||
server: http.Server,
|
||||
serverClosed: boolean,
|
||||
resolve: (value: AuthCredentials) => void,
|
||||
reject: (error: any) => void
|
||||
): Promise<void> {
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Send response to browser
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
|
||||
if (error) {
|
||||
res.end(getErrorHtml(errorDescription || error));
|
||||
if (!serverClosed) {
|
||||
server.close();
|
||||
reject(
|
||||
new AuthenticationError(
|
||||
errorDescription || error || 'Authentication failed',
|
||||
'OAUTH_ERROR'
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify state parameter for CSRF protection
|
||||
if (returnedState !== this.originalState) {
|
||||
res.end(getSecurityErrorHtml());
|
||||
if (!serverClosed) {
|
||||
server.close();
|
||||
reject(
|
||||
new AuthenticationError('Invalid state parameter', 'INVALID_STATE')
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
res.end(getSuccessHtml());
|
||||
|
||||
try {
|
||||
// Exchange authorization code for tokens
|
||||
const response = await this.apiClient.exchangeCode(code);
|
||||
|
||||
// Save authentication data
|
||||
const authData: AuthCredentials = {
|
||||
token: response.token,
|
||||
refreshToken: response.refreshToken,
|
||||
userId: response.userId,
|
||||
email: response.email,
|
||||
expiresAt: response.expiresAt,
|
||||
tokenType: 'standard',
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.credentialStore.saveCredentials(authData);
|
||||
|
||||
if (!serverClosed) {
|
||||
server.close();
|
||||
resolve(authData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!serverClosed) {
|
||||
server.close();
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open browser with the given URL
|
||||
*/
|
||||
private async openBrowser(url: string): Promise<void> {
|
||||
try {
|
||||
await open(url);
|
||||
this.logger.debug('Browser opened successfully with URL:', url);
|
||||
} catch (error) {
|
||||
// Log the error but don't throw - user can still manually open the URL
|
||||
this.logger.warn('Failed to open browser automatically:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate state for OAuth flow
|
||||
*/
|
||||
private generateState(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random available port
|
||||
*/
|
||||
private async getRandomPort(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = (server.address() as any).port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLI version from package.json if available
|
||||
*/
|
||||
private getCliVersion(): string {
|
||||
return packageJson.version || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL (for browser opening)
|
||||
*/
|
||||
getAuthorizationUrl(): string | null {
|
||||
return this.authorizationUrl;
|
||||
}
|
||||
}
|
||||
137
packages/tm-core/src/auth/templates.ts
Normal file
137
packages/tm-core/src/auth/templates.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* HTML templates for OAuth callback responses
|
||||
*/
|
||||
|
||||
export function getSuccessHtml(): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #28a745;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.checkmark {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
||||
<circle cx="26" cy="26" r="25" fill="none" stroke="#28a745" stroke-width="2"/>
|
||||
<path fill="none" stroke="#28a745" stroke-width="3" d="M14 27l7 7 16-16"/>
|
||||
</svg>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>You can close this window and return to your terminal.</p>
|
||||
<p style="color: #999; font-size: 0.9rem; margin-top: 2rem;">Task Master CLI</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function getErrorHtml(errorMessage: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Failed</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #dc3545;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>❌ Authentication Failed</h1>
|
||||
<p>${errorMessage}</p>
|
||||
<p>You can close this window and try again.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function getSecurityErrorHtml(): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Security Error</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #dc3545;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>⚠️ Security Error</h1>
|
||||
<p>Invalid state parameter. Please try again.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
73
packages/tm-core/src/auth/types.ts
Normal file
73
packages/tm-core/src/auth/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Authentication types and interfaces
|
||||
*/
|
||||
|
||||
export interface AuthCredentials {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
tokenType?: 'standard' | 'api_key';
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export interface AuthOptions {
|
||||
email?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface OAuthFlowOptions {
|
||||
/** Whether to automatically open the browser. Default: true */
|
||||
openBrowser?: boolean;
|
||||
/** Timeout for the OAuth flow in milliseconds. Default: 300000 (5 minutes) */
|
||||
timeout?: number;
|
||||
/** Callback to be invoked with the authorization URL */
|
||||
onAuthUrl?: (url: string) => void;
|
||||
/** Callback to be invoked when waiting for authentication */
|
||||
onWaitingForAuth?: () => void;
|
||||
/** Callback to be invoked on successful authentication */
|
||||
onSuccess?: (credentials: AuthCredentials) => void;
|
||||
/** Callback to be invoked on authentication error */
|
||||
onError?: (error: AuthenticationError) => void;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
apiBaseUrl: string;
|
||||
webBaseUrl: string;
|
||||
configDir: string;
|
||||
configFile: string;
|
||||
}
|
||||
|
||||
export interface CliData {
|
||||
callback: string;
|
||||
state: string;
|
||||
name: string;
|
||||
version: string;
|
||||
device?: string;
|
||||
user?: string;
|
||||
platform?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error class
|
||||
*/
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,10 @@ export {
|
||||
AuthenticationError,
|
||||
type AuthCredentials,
|
||||
type AuthOptions,
|
||||
type AuthResponse
|
||||
} from './auth/auth-manager';
|
||||
type AuthResponse,
|
||||
type OAuthFlowOptions,
|
||||
type AuthConfig
|
||||
} from './auth';
|
||||
|
||||
// Re-export logger
|
||||
export { getLogger, createLogger, setGlobalLogger } from './logger';
|
||||
|
||||
@@ -31,10 +31,13 @@ export function getLogger(name?: string, config?: LoggerConfig): Logger {
|
||||
|
||||
// Check if named logger exists
|
||||
if (!loggers.has(name)) {
|
||||
loggers.set(name, createLogger({
|
||||
prefix: name,
|
||||
...config
|
||||
}));
|
||||
loggers.set(
|
||||
name,
|
||||
createLogger({
|
||||
prefix: name,
|
||||
...config
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return loggers.get(name)!;
|
||||
@@ -53,4 +56,4 @@ export function setGlobalLogger(logger: Logger): void {
|
||||
export function clearLoggers(): void {
|
||||
globalLogger = null;
|
||||
loggers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
|
||||
export { Logger, LogLevel } from './logger.js';
|
||||
export type { LoggerConfig } from './logger.js';
|
||||
export { createLogger, getLogger, setGlobalLogger } from './factory.js';
|
||||
export { createLogger, getLogger, setGlobalLogger } from './factory.js';
|
||||
|
||||
@@ -36,27 +36,40 @@ export class Logger {
|
||||
constructor(config: LoggerConfig = {}) {
|
||||
// Check environment variables
|
||||
const envConfig: LoggerConfig = {};
|
||||
|
||||
|
||||
// Check for MCP mode
|
||||
if (process.env.MCP_MODE === 'true' || process.env.TASK_MASTER_MCP === 'true') {
|
||||
if (
|
||||
process.env.MCP_MODE === 'true' ||
|
||||
process.env.TASK_MASTER_MCP === 'true'
|
||||
) {
|
||||
envConfig.mcpMode = true;
|
||||
}
|
||||
|
||||
|
||||
// Check for silent mode
|
||||
if (process.env.TASK_MASTER_SILENT === 'true' || process.env.TM_SILENT === 'true') {
|
||||
if (
|
||||
process.env.TASK_MASTER_SILENT === 'true' ||
|
||||
process.env.TM_SILENT === 'true'
|
||||
) {
|
||||
envConfig.silent = true;
|
||||
}
|
||||
|
||||
|
||||
// Check for log level
|
||||
if (process.env.TASK_MASTER_LOG_LEVEL || process.env.TM_LOG_LEVEL) {
|
||||
const levelStr = (process.env.TASK_MASTER_LOG_LEVEL || process.env.TM_LOG_LEVEL || '').toUpperCase();
|
||||
const levelStr = (
|
||||
process.env.TASK_MASTER_LOG_LEVEL ||
|
||||
process.env.TM_LOG_LEVEL ||
|
||||
''
|
||||
).toUpperCase();
|
||||
if (levelStr in LogLevel) {
|
||||
envConfig.level = LogLevel[levelStr as keyof typeof LogLevel];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check for no colors
|
||||
if (process.env.NO_COLOR === 'true' || process.env.TASK_MASTER_NO_COLOR === 'true') {
|
||||
if (
|
||||
process.env.NO_COLOR === 'true' ||
|
||||
process.env.TASK_MASTER_NO_COLOR === 'true'
|
||||
) {
|
||||
envConfig.colors = false;
|
||||
}
|
||||
|
||||
@@ -86,18 +99,26 @@ export class Logger {
|
||||
/**
|
||||
* Format a log message
|
||||
*/
|
||||
private formatMessage(level: LogLevel, message: string, ...args: any[]): string {
|
||||
private formatMessage(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
...args: any[]
|
||||
): string {
|
||||
let formatted = '';
|
||||
|
||||
// Add timestamp if enabled
|
||||
if (this.config.timestamp) {
|
||||
const timestamp = new Date().toISOString();
|
||||
formatted += this.config.colors ? chalk.gray(`[${timestamp}] `) : `[${timestamp}] `;
|
||||
formatted += this.config.colors
|
||||
? chalk.gray(`[${timestamp}] `)
|
||||
: `[${timestamp}] `;
|
||||
}
|
||||
|
||||
// Add prefix if configured
|
||||
if (this.config.prefix) {
|
||||
formatted += this.config.colors ? chalk.cyan(`[${this.config.prefix}] `) : `[${this.config.prefix}] `;
|
||||
formatted += this.config.colors
|
||||
? chalk.cyan(`[${this.config.prefix}] `)
|
||||
: `[${this.config.prefix}] `;
|
||||
}
|
||||
|
||||
// Skip level indicator for cleaner output
|
||||
@@ -124,9 +145,13 @@ export class Logger {
|
||||
|
||||
// Add any additional arguments
|
||||
if (args.length > 0) {
|
||||
formatted += ' ' + args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
|
||||
).join(' ');
|
||||
formatted +=
|
||||
' ' +
|
||||
args
|
||||
.map((arg) =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return formatted;
|
||||
@@ -170,7 +195,7 @@ export class Logger {
|
||||
*/
|
||||
log(message: string, ...args: any[]): void {
|
||||
if (this.config.silent || this.config.mcpMode) return;
|
||||
|
||||
|
||||
if (args.length > 0) {
|
||||
console.log(message, ...args);
|
||||
} else {
|
||||
@@ -204,14 +229,14 @@ export class Logger {
|
||||
* Create a child logger with a prefix
|
||||
*/
|
||||
child(prefix: string, config?: Partial<LoggerConfig>): Logger {
|
||||
const childPrefix = this.config.prefix
|
||||
const childPrefix = this.config.prefix
|
||||
? `${this.config.prefix}:${prefix}`
|
||||
: prefix;
|
||||
|
||||
|
||||
return new Logger({
|
||||
...this.config,
|
||||
...config,
|
||||
prefix: childPrefix
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ export class StorageFactory {
|
||||
...config.storage,
|
||||
type: 'api' as const,
|
||||
apiAccessToken: credentials.token,
|
||||
apiEndpoint: config.storage?.apiEndpoint || process.env.HAMSTER_API_URL || 'https://tryhamster.com/api'
|
||||
apiEndpoint:
|
||||
config.storage?.apiEndpoint ||
|
||||
process.env.HAMSTER_API_URL ||
|
||||
'https://tryhamster.com/api'
|
||||
} as any; // Cast to any to bypass strict type checking for partial config
|
||||
}
|
||||
}
|
||||
@@ -62,13 +65,13 @@ export class StorageFactory {
|
||||
case 'auto':
|
||||
// Auto-detect based on authentication status
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
|
||||
// First check if API credentials are explicitly configured
|
||||
if (StorageFactory.isHamsterAvailable(config)) {
|
||||
logger.info('☁️ Using API storage (configured)');
|
||||
return StorageFactory.createApiStorage(config);
|
||||
}
|
||||
|
||||
|
||||
// Then check if authenticated via AuthManager
|
||||
if (authManager.isAuthenticated()) {
|
||||
const credentials = authManager.getCredentials();
|
||||
@@ -78,13 +81,16 @@ export class StorageFactory {
|
||||
...config.storage,
|
||||
type: 'api' as const,
|
||||
apiAccessToken: credentials.token,
|
||||
apiEndpoint: config.storage?.apiEndpoint || process.env.HAMSTER_API_URL || 'https://tryhamster.com/api'
|
||||
apiEndpoint:
|
||||
config.storage?.apiEndpoint ||
|
||||
process.env.HAMSTER_API_URL ||
|
||||
'https://tryhamster.com/api'
|
||||
} as any; // Cast to any to bypass strict type checking for partial config
|
||||
logger.info('☁️ Using API storage (authenticated)');
|
||||
return StorageFactory.createApiStorage(config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Default to file storage
|
||||
logger.debug('📁 Using local file storage');
|
||||
return StorageFactory.createFileStorage(projectPath, config);
|
||||
|
||||
Reference in New Issue
Block a user