@withkeystone/cli
Version:
Keystone CLI - Test automation for modern web apps
332 lines • 11.7 kB
JavaScript
"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 `
<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 `
<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 `
<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