niledatabase
Version:
Command line interface for Nile databases
268 lines (237 loc) • 9.45 kB
text/typescript
import http from 'http';
import open from 'open';
import axios from 'axios';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { TokenResponse } from './types';
import { ConfigManager } from './config';
export class Auth {
private static DEFAULT_CLIENT_ID = 'nilecli';
private static AUTH_TIMEOUT = 120_000; // 2 minutes
private static generateCodeVerifier(): string {
const buffer = crypto.randomBytes(32);
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
.substring(0, 128);
}
private static async generateCodeChallenge(verifier: string): Promise<string> {
const buffer = Buffer.from(verifier);
const hash = crypto.createHash('sha256').update(buffer).digest();
return hash
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
private static generateState(): string {
return crypto.randomBytes(4).toString('hex');
}
private static async findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(0, () => {
const address = server.address();
if (address && typeof address === 'object') {
const port = address.port;
server.close(() => resolve(port));
} else {
server.close(() => reject(new Error('Could not find available port')));
}
});
});
}
private static getAuthUrl(configManager: ConfigManager): string {
const authDomain = configManager.getAuthUrl() || 'console.thenile.dev';
return `https://${authDomain}/authorize`;
}
private static getTokenUrl(configManager: ConfigManager): string {
const domain = configManager.getGlobalHost();
return `https://${domain}/oauth2/token`;
}
private static getCallbackHtml(): string {
try {
let html = fs.readFileSync(path.join(__dirname, 'callback.html'), 'utf8');
const logoPath = path.join(__dirname, 'logo.jpg');
const logoBase64 = fs.readFileSync(logoPath, 'base64');
html = html.replace('LOGO_BASE64_PLACEHOLDER', logoBase64);
return html;
} catch {
// Fallback HTML if file not found
return `
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; text-align: center; padding: 2rem;">
<h1 style="color: #2C7A7B;">Authentication Successful</h1>
<p>You can close this window and return to your terminal.</p>
</body>
</html>
`;
}
}
static async getAuthorizationToken(
configManager: ConfigManager): Promise<string> {
if (configManager.getDebug()) {
console.log('Debug - Starting auth with config:', {
clientId: Auth.DEFAULT_CLIENT_ID,
authUrl: configManager.getAuthUrl(),
tokenUrl: this.getTokenUrl(configManager),
debug: configManager.getDebug()
});
}
return new Promise((resolve, reject) => {
const codeVerifier = this.generateCodeVerifier();
const state = this.generateState();
// Start the process
this.findAvailablePort()
.then(port => {
if (configManager.getDebug()) {
console.log('Debug - Auth parameters:', {
clientId: Auth.DEFAULT_CLIENT_ID,
authUrl: this.getAuthUrl(configManager),
tokenUrl: this.getTokenUrl(configManager),
port,
codeVerifier: codeVerifier.substring(0, 5) + '...',
state
});
}
const server = http.createServer(async (req, res) => {
if (configManager.getDebug()) {
console.log('Debug - Received callback request:', req.url);
}
// Immediately return for favicon requests
if (req.url === '/favicon.ico') {
res.writeHead(204);
res.end();
return;
}
// Only process /callback path
if (!req.url || !req.url.includes('/callback')) {
res.writeHead(404);
res.end('Not found');
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
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');
// Close server and respond with error
const closeServerAndReject = (status: number, message: string, error?: Error) => {
res.writeHead(status, { 'Content-Type': 'text/html' });
res.end(message);
server.close(() => {
if (error) {
reject(error);
}
});
};
// Handle authentication errors
if (error) {
closeServerAndReject(400, `Authentication failed: ${errorDescription || error}`, new Error(errorDescription || error));
return;
}
// Validate state parameter
if (returnedState !== state) {
if (configManager.getDebug()) {
console.log('Debug - State mismatch:', {
expected: state,
received: returnedState
});
}
closeServerAndReject(400, 'Authentication failed: Invalid state parameter', new Error('Invalid state parameter'));
return;
}
// Ensure we have an authorization code
if (!code) {
if (configManager.getDebug()) {
console.log('Debug - No code received in callback');
}
closeServerAndReject(400, 'Authentication failed: No authorization code received', new Error('No authorization code received'));
return;
}
try {
if (configManager.getDebug()) {
console.log('Debug - Exchanging code for token...');
}
const tokenResponse = await axios.post<TokenResponse>(
this.getTokenUrl(configManager),
new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: Auth.DEFAULT_CLIENT_ID,
code_verifier: codeVerifier,
redirect_uri: `http://localhost:${port}/callback`,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
if (configManager.getDebug()) {
console.log('Debug - Token exchange successful');
}
// Send success response with custom HTML
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(this.getCallbackHtml());
// Close server and resolve with token
server.close(() => {
resolve(tokenResponse.data.access_token);
});
} catch (error) {
if (configManager.getDebug()) {
console.log('Debug - Token exchange failed:', error);
if (axios.isAxiosError(error) && error.response) {
console.log('Debug - Error response:', error.response.data);
}
}
closeServerAndReject(400, 'Authentication failed during token exchange. Please try again.',
axios.isAxiosError(error) && error.response
? new Error(`Token exchange failed: ${error.response.data.error_description || error.response.data.error || error.message}`)
: error as Error
);
}
});
// Set up server and timeout
let timeoutId: NodeJS.Timeout;
server.listen(port, async () => {
try {
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
const params = new URLSearchParams({
client_id: Auth.DEFAULT_CLIENT_ID,
state,
response_type: 'code',
redirect_uri: `http://localhost:${port}/callback`,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
const authUrl = `${this.getAuthUrl(configManager)}?${params.toString()}`;
if (configManager.getDebug()) {
console.log('Debug - Full auth URL:', authUrl);
}
console.log('Opening authorization URL:', authUrl);
await open(authUrl);
// Set timeout after server starts
timeoutId = setTimeout(() => {
server.close();
reject(new Error('Authentication timed out after 2 minutes'));
}, Auth.AUTH_TIMEOUT);
} catch (error) {
server.close();
reject(error);
}
});
// Clean up on server close
server.on('close', () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
});
});
}
}