chore: drop stale hanzo-auth.ts.bak
Fossilized copy left over from an earlier rewrite of packages/tools/src/auth. The live hanzo-auth.ts is the source of truth.
This commit is contained in:
@@ -1,478 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface HanzoAuthConfig {
|
||||
apiUrl: string;
|
||||
iamUrl: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export interface HanzoCredentials {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface APIKeyInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
masked?: string;
|
||||
}
|
||||
|
||||
export class HanzoAuth extends EventEmitter {
|
||||
private config: HanzoAuthConfig;
|
||||
private credentialsPath: string;
|
||||
private settingsPath: string;
|
||||
private credentials: HanzoCredentials | null = null;
|
||||
private server?: http.Server;
|
||||
|
||||
constructor(config: Partial<HanzoAuthConfig> = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
apiUrl: config.apiUrl || 'https://api.hanzo.ai',
|
||||
iamUrl: config.iamUrl || 'https://iam.hanzo.ai',
|
||||
clientId: config.clientId || 'hanzo-dev-cli',
|
||||
scope: config.scope || 'api:access tools:manage',
|
||||
configPath: config.configPath || path.join(os.homedir(), '.hanzo')
|
||||
};
|
||||
|
||||
// Ensure config directory exists
|
||||
fs.mkdirSync(this.config.configPath!, { recursive: true });
|
||||
|
||||
this.credentialsPath = path.join(this.config.configPath!, 'credentials.json');
|
||||
this.settingsPath = path.join(this.config.configPath!, 'settings.json');
|
||||
|
||||
// Load existing credentials
|
||||
this.loadCredentials();
|
||||
}
|
||||
|
||||
private loadCredentials(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.credentialsPath)) {
|
||||
const data = fs.readFileSync(this.credentialsPath, 'utf-8');
|
||||
this.credentials = JSON.parse(data);
|
||||
|
||||
// Check if token is expired
|
||||
if (this.credentials?.expiresAt && this.credentials.expiresAt < Date.now()) {
|
||||
this.emit('token:expired');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private saveCredentials(): void {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.credentialsPath,
|
||||
JSON.stringify(this.credentials, null, 2),
|
||||
{ mode: 0o600 } // Secure file permissions
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Generate PKCE challenge
|
||||
const codeVerifier = this.generateCodeVerifier();
|
||||
const codeChallenge = this.generateCodeChallenge(codeVerifier);
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Start local server for OAuth callback
|
||||
const port = 51234;
|
||||
this.server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
|
||||
if (returnedState !== state) {
|
||||
res.writeHead(400);
|
||||
res.end('Invalid state parameter');
|
||||
this.server?.close();
|
||||
reject(new Error('Invalid state parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
// Exchange code for tokens
|
||||
try {
|
||||
await this.exchangeCodeForTokens(code, codeVerifier);
|
||||
|
||||
// Success response
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Hanzo Dev - Login Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a1a; color: white; }
|
||||
.container { text-align: center; }
|
||||
.success { color: #4ade80; font-size: 48px; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✓</div>
|
||||
<h1>Login Successful!</h1>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
this.server?.close();
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Failed to exchange code for tokens');
|
||||
this.server?.close();
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('No authorization code received');
|
||||
this.server?.close();
|
||||
reject(new Error('No authorization code received'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(port, () => {
|
||||
// Build authorization URL
|
||||
const authUrl = new URL('/oauth/authorize', this.config.iamUrl);
|
||||
authUrl.searchParams.set('client_id', this.config.clientId);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
|
||||
authUrl.searchParams.set('scope', this.config.scope);
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
// Open browser
|
||||
this.openBrowser(authUrl.toString());
|
||||
|
||||
console.log('Opening browser for authentication...');
|
||||
console.log('If browser doesn\'t open, visit:', authUrl.toString());
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
reject(new Error('Login timeout'));
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<void> {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: 'http://localhost:51234/callback'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to exchange code: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.credentials = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: Date.now() + (data.expires_in * 1000),
|
||||
...this.credentials // Preserve existing settings
|
||||
};
|
||||
|
||||
// Fetch user info
|
||||
await this.fetchUserInfo();
|
||||
|
||||
// Fetch and sync API keys
|
||||
await this.syncAPIKeys();
|
||||
|
||||
this.saveCredentials();
|
||||
this.emit('login:success', this.credentials);
|
||||
}
|
||||
|
||||
private async fetchUserInfo(): Promise<void> {
|
||||
if (!this.credentials?.accessToken) return;
|
||||
|
||||
const response = await fetch(`${this.config.iamUrl}/api/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
this.credentials.userId = user.id;
|
||||
this.credentials.email = user.email;
|
||||
}
|
||||
}
|
||||
|
||||
async syncAPIKeys(): Promise<APIKeyInfo[]> {
|
||||
if (!this.credentials?.accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.apiUrl}/v1/api-keys`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API keys: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiKeys = await response.json();
|
||||
|
||||
// Store API keys in settings (encrypted)
|
||||
const settings = this.loadSettings();
|
||||
settings.apiKeys = apiKeys.map((key: any) => ({
|
||||
name: key.name,
|
||||
provider: key.provider,
|
||||
enabled: key.enabled,
|
||||
// Store encrypted version locally
|
||||
encryptedKey: this.encryptData(key.key),
|
||||
masked: key.key.substring(0, 8) + '...' + key.key.substring(key.key.length - 4)
|
||||
}));
|
||||
|
||||
this.saveSettings(settings);
|
||||
this.emit('apikeys:synced', apiKeys.length);
|
||||
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
getAPIKey(provider: string): string | null {
|
||||
const settings = this.loadSettings();
|
||||
const keyInfo = settings.apiKeys?.find((k: any) =>
|
||||
k.provider === provider && k.enabled
|
||||
);
|
||||
|
||||
if (!keyInfo?.encryptedKey) {
|
||||
// Try environment variable as fallback
|
||||
const envKey = `${provider.toUpperCase()}_API_KEY`;
|
||||
return process.env[envKey] || null;
|
||||
}
|
||||
|
||||
return this.decryptData(keyInfo.encryptedKey);
|
||||
}
|
||||
|
||||
async refreshToken(): Promise<boolean> {
|
||||
if (!this.credentials?.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.iamUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.credentials.refreshToken,
|
||||
client_id: this.config.clientId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh token: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || this.credentials.refreshToken,
|
||||
expiresAt: Date.now() + (data.expires_in * 1000)
|
||||
};
|
||||
|
||||
this.saveCredentials();
|
||||
this.emit('token:refreshed');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit('token:refresh:failed', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (this.credentials?.accessToken) {
|
||||
try {
|
||||
await fetch(`${this.config.iamUrl}/oauth/revoke`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: this.credentials.refreshToken || this.credentials.accessToken
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear credentials but keep settings
|
||||
this.credentials = null;
|
||||
if (fs.existsSync(this.credentialsPath)) {
|
||||
fs.unlinkSync(this.credentialsPath);
|
||||
}
|
||||
|
||||
this.emit('logout');
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
if (!this.credentials?.accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (this.credentials.expiresAt && this.credentials.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getCredentials(): HanzoCredentials | null {
|
||||
return this.credentials;
|
||||
}
|
||||
|
||||
async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
if (!this.isAuthenticated()) {
|
||||
// Try to refresh token
|
||||
if (this.credentials?.refreshToken) {
|
||||
const refreshed = await this.refreshToken();
|
||||
if (!refreshed) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${this.credentials!.accessToken}`
|
||||
};
|
||||
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
private loadSettings(): Record<string, any> {
|
||||
try {
|
||||
if (fs.existsSync(this.settingsPath)) {
|
||||
return JSON.parse(fs.readFileSync(this.settingsPath, 'utf-8'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private saveSettings(settings: Record<string, any>): void {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.settingsPath,
|
||||
JSON.stringify(settings, null, 2),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private encryptData(data: string): string {
|
||||
// Use machine ID as encryption key
|
||||
const key = crypto.scryptSync(this.getMachineId(), 'salt', 32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
private decryptData(encryptedData: string): string {
|
||||
const [ivHex, encrypted] = encryptedData.split(':');
|
||||
const key = crypto.scryptSync(this.getMachineId(), 'salt', 32);
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
private getMachineId(): string {
|
||||
// Simple machine ID based on hostname and platform
|
||||
return crypto.createHash('sha256')
|
||||
.update(os.hostname())
|
||||
.update(os.platform())
|
||||
.update(os.homedir())
|
||||
.digest('hex')
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
private generateCodeVerifier(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
private generateCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash('sha256')
|
||||
.update(verifier)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
private openBrowser(url: string): void {
|
||||
const platform = os.platform();
|
||||
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawn('open', [url], { detached: true });
|
||||
} else if (platform === 'win32') {
|
||||
spawn('start', ['', url], { shell: true, detached: true });
|
||||
} else {
|
||||
spawn('xdg-open', [url], { detached: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open browser:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user