UNPKG

@withkeystone/cli

Version:

Keystone CLI - Test automation for modern web apps

332 lines 11.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PKCEAuthenticator = void 0; const crypto_1 = __importDefault(require("crypto")); const http_1 = __importDefault(require("http")); const open_1 = __importDefault(require("open")); class PKCEAuthenticator { codeVerifier; codeChallenge; state; config; constructor(config) { this.config = config; // Generate PKCE parameters this.codeVerifier = this.generateCodeVerifier(); this.codeChallenge = this.generateCodeChallenge(this.codeVerifier); this.state = crypto_1.default.randomUUID(); } generateCodeVerifier() { return crypto_1.default.randomBytes(32).toString('base64url'); } generateCodeChallenge(verifier) { return crypto_1.default .createHash('sha256') .update(verifier) .digest('base64url'); } async getAvailablePort() { return new Promise((resolve, reject) => { const server = http_1.default.createServer(); server.listen(0, () => { const address = server.address(); if (address && typeof address !== 'string') { const port = address.port; server.close(() => resolve(port)); } else { reject(new Error('Failed to get available port')); } }); server.on('error', reject); }); } async authenticate() { const port = await this.getAvailablePort(); const server = http_1.default.createServer(); return new Promise((resolve, reject) => { let timeout; server.on('request', 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 !== this.state) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(this.getErrorHTML('Invalid state parameter')); reject(new Error('Invalid state parameter')); return; } if (!code) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(this.getErrorHTML('Missing authorization code')); reject(new Error('Missing authorization code')); return; } try { // Exchange code for tokens const tokens = await this.exchangeCodeForTokens(code); // Send success page res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(this.getSuccessHTML()); clearTimeout(timeout); server.close(); resolve(tokens); } catch (error) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end(this.getErrorHTML('Token exchange failed')); reject(error); } } else { // Serve a simple waiting page for the root path res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(this.getWaitingHTML()); } }); server.listen(port, async () => { const redirectUri = `http://localhost:${port}/callback`; console.log(`Opening browser for authentication...`); console.log(`${this.config.apiUrl}/api/v1/cli/auth/start`); // Start PKCE flow try { const startResponse = await fetch(`${this.config.apiUrl}/api/v1/cli/auth/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: this.state, code_challenge: this.codeChallenge, code_challenge_method: 'S256', redirect_uri: redirectUri }) }); console.log("START RESPONSE", startResponse); if (!startResponse.ok) { throw new Error('Failed to start authentication flow'); } const { auth_url } = await startResponse.json(); console.log("AUTH URL", auth_url); // Append code_verifier to the auth URL so frontend can use it const authUrlWithVerifier = `${auth_url}&verifier=${encodeURIComponent(this.codeVerifier)}`; console.log("AUTH URL WITH VERIFIER", authUrlWithVerifier); // Open browser await (0, open_1.default)(authUrlWithVerifier); console.log("BROWSER OPENED"); } catch (error) { server.close(); reject(error); } }); // 5 minute timeout timeout = setTimeout(() => { server.close(); reject(new Error('Authentication timeout')); }, 5 * 60 * 1000); }); } async exchangeCodeForTokens(code) { const response = await fetch(`${this.config.apiUrl}/api/v1/cli/auth/exchange?code=${code}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to exchange code for tokens: ${error}`); } const data = await response.json(); return { access_token: data.access_token, refresh_token: data.refresh_token, expires_in: data.expires_in }; } getSuccessHTML() { return ` <!DOCTYPE html> <html> <head> <title>Authentication Successful</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f5f5f5; } .container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .success-icon { width: 64px; height: 64px; margin: 0 auto 1rem; background-color: #10b981; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .success-icon svg { width: 32px; height: 32px; fill: white; } h1 { color: #111827; margin: 0 0 0.5rem; } p { color: #6b7280; margin: 0; } </style> </head> <body> <div class="container"> <div class="success-icon"> <svg viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> </svg> </div> <h1>Authentication Successful!</h1> <p>You can close this window and return to your terminal.</p> </div> <script> setTimeout(() => window.close(), 3000); </script> </body> </html> `; } getErrorHTML(message) { return ` <!DOCTYPE html> <html> <head> <title>Authentication Failed</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f5f5f5; } .container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .error-icon { width: 64px; height: 64px; margin: 0 auto 1rem; background-color: #ef4444; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .error-icon svg { width: 32px; height: 32px; fill: white; } h1 { color: #111827; margin: 0 0 0.5rem; } p { color: #6b7280; margin: 0; } </style> </head> <body> <div class="container"> <div class="error-icon"> <svg viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /> </svg> </div> <h1>Authentication Failed</h1> <p>${message}</p> </div> </body> </html> `; } getWaitingHTML() { return ` <!DOCTYPE html> <html> <head> <title>Waiting for Authentication</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f5f5f5; } .container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } .spinner { width: 48px; height: 48px; margin: 0 auto 1rem; border: 3px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } h1 { color: #111827; margin: 0 0 0.5rem; } p { color: #6b7280; margin: 0; } </style> </head> <body> <div class="container"> <div class="spinner"></div> <h1>Waiting for Authentication</h1> <p>Please complete the authentication in your browser.</p> </div> </body> </html> `; } } exports.PKCEAuthenticator = PKCEAuthenticator; //# sourceMappingURL=PKCEAuthenticator.js.map