Files
glitternetwork--pinme/bin/utils/webLogin.ts
T
2026-07-13 12:30:28 +08:00

586 lines
18 KiB
TypeScript

import crypto from 'crypto';
import http from 'http';
import { URL } from 'url';
import chalk from 'chalk';
import { exec } from 'child_process';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { APP_CONFIG } from './config';
/* c8 ignore start -- Browser callback/login UI is covered by manual/e2e flows; token helpers below are unit-tested. */
/* Stryker disable all: Browser opening is OS integration; token helpers below are mutation-tested. */
// Cross-platform browser opener
function 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 {
// linux
command = `xdg-open "${url}"`;
}
exec(command, (err) => {
if (err) {
console.log(chalk.yellow(`Unable to open browser automatically. Please visit manually: ${url}`));
}
});
}
/* Stryker restore all */
const CONFIG_DIR = path.join(os.homedir(), '.pinme');
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
export interface AuthConfig {
address: string;
token: string;
expires_at?: number;
user_id?: string;
email?: string;
}
export interface LoginOptions {
apiBaseUrl?: string;
webBaseUrl?: string;
callbackPort?: number;
callbackPath?: string;
}
const DEFAULT_OPTIONS: Required<LoginOptions> = {
apiBaseUrl: APP_CONFIG.pinmeApiBase,
webBaseUrl: APP_CONFIG.pinmeWebUrl,
callbackPort: 34567,
callbackPath: '/cli/callback',
};
/* Stryker disable all: Interactive browser login and callback HTML are covered by manual/e2e flows. */
export class WebLoginManager {
private config: Required<LoginOptions>;
private server: http.Server | null = null;
private resolvePromise: ((value: string) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private loginToken: string = '';
constructor(options: LoginOptions = {}) {
this.config = { ...DEFAULT_OPTIONS, ...options };
}
async login(): Promise<AuthConfig> {
console.log(chalk.blue('Starting login flow...\n'));
// 1. Generate temporary login token
this.loginToken = this.generateLoginToken();
// 2. Start local server to wait for callback
console.log(chalk.blue('Starting local callback server...'));
await this.startCallbackServer();
try {
// 3. Build login URL and open browser
const loginUrl = this.buildLoginUrl();
console.log(chalk.blue('Opening browser...'));
console.log(chalk.white('If browser does not open automatically, please visit manually:'));
console.log(chalk.cyan(` ${loginUrl}\n`));
openBrowser(loginUrl);
console.log(chalk.yellow('Please complete login in browser...'));
console.log(chalk.gray('Browser will close automatically after successful login.\n'));
// 4. Wait for user to complete login
const authToken = await this.waitForCallback();
// 5. Parse token
const authConfig = this.parseAuthToken(authToken);
// 6. Save auth config
this.saveAuthConfig(authConfig);
console.log(chalk.green('\nLogin successful!'));
if (authConfig.email) {
console.log(chalk.green(`Welcome, ${authConfig.email}`));
}
console.log(chalk.gray(`Address: ${authConfig.address}`));
return authConfig;
} catch (error: any) {
console.error(chalk.red(`\nLogin failed: ${error.message}`));
throw error;
} finally {
this.closeServer();
}
}
private generateLoginToken(): string {
return crypto.randomBytes(32).toString('hex');
}
private async startCallbackServer(): Promise<void> {
return new Promise((resolve, reject) => {
this.server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '', `http://localhost:${this.config.callbackPort}`);
if (url.pathname === this.config.callbackPath) {
const authToken = url.searchParams.get('token');
const error = url.searchParams.get('error');
const loginToken = url.searchParams.get('login_token');
if (error) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml(error));
if (this.rejectPromise) {
this.rejectPromise(new Error(error));
}
return;
}
if (!authToken) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml('Auth token not received'));
if (this.rejectPromise) {
this.rejectPromise(new Error('Auth token not received'));
}
return;
}
// Verify loginToken
if (loginToken !== this.loginToken) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml('Login token invalid or expired'));
if (this.rejectPromise) {
this.rejectPromise(new Error('Login token invalid or expired'));
}
return;
}
// Return success page
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getSuccessHtml());
// Pass token
if (this.resolvePromise) {
this.resolvePromise(authToken);
}
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
} catch (err: any) {
console.error('Callback error:', err);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
});
this.server.on('error', (err) => {
reject(err);
});
this.server.listen(this.config.callbackPort, '127.0.0.1', () => {
console.log(chalk.gray(`Local server: http://localhost:${this.config.callbackPort}`));
resolve();
});
// Set timeout (10 minutes)
setTimeout(() => {
if (this.rejectPromise) {
this.rejectPromise(new Error('Login timeout, please try again'));
}
this.closeServer();
}, 10 * 60 * 1000);
});
}
private buildLoginUrl(): string {
const callbackUrl = `http://localhost:${this.config.callbackPort}${this.config.callbackPath}`;
const url = `${this.config.webBaseUrl}/#/cli-login?` +
`login_token=${this.loginToken}&` +
`callback_url=${encodeURIComponent(callbackUrl)}`;
return url;
}
private waitForCallback(): Promise<string> {
return new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
}
private parseAuthToken(authToken: string): AuthConfig {
// authToken format: "<address>-<jwt>"
const firstDash = authToken.indexOf('-');
if (firstDash <= 0 || firstDash === authToken.length - 1) {
throw new Error('Invalid token format');
}
const address = authToken.slice(0, firstDash).trim();
const token = authToken.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Token parsing failed: address or token is empty');
}
return { address, token };
}
private saveAuthConfig(config: AuthConfig): void {
fs.ensureDirSync(CONFIG_DIR);
fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 });
// Set file permissions (only current user can read)
fs.chmodSync(AUTH_FILE, 0o600);
}
private closeServer(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
// HTML templates
private getSuccessHtml(): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>Login Success - PinMe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #000;
overflow: hidden;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 20% 80%, rgba(120, 0, 255, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(0, 200, 255, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(255, 0, 150, 0.15) 0%, transparent 60%);
animation: bgPulse 6s ease-in-out infinite;
}
@keyframes bgPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.grid {
position: fixed;
top: 0;
left: 0;
width: 200%;
height: 200%;
background-image:
linear-gradient(rgba(0, 200, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 200, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
transform: perspective(500px) rotateX(60deg) translateY(-50%) translateZ(-200px);
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: perspective(500px) rotateX(60deg) translateY(0) translateZ(-200px); }
100% { transform: perspective(500px) rotateX(60deg) translateY(50px) translateZ(-200px); }
}
.container {
position: relative;
z-index: 10;
background: linear-gradient(135deg, rgba(20, 20, 40, 0.9) 0%, rgba(10, 10, 30, 0.95) 100%);
padding: 3.5rem 4rem;
border-radius: 32px;
box-shadow:
0 0 60px rgba(0, 200, 255, 0.15),
0 25px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 200, 255, 0.1);
text-align: center;
border: 1px solid rgba(0, 200, 255, 0.2);
max-width: 440px;
backdrop-filter: blur(30px);
}
.container::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: 32px;
background: linear-gradient(135deg, rgba(0, 200, 255, 0.5), rgba(255, 0, 150, 0.5), rgba(120, 0, 255, 0.5));
z-index: -1;
opacity: 0.5;
animation: borderGlow 3s ease-in-out infinite;
}
@keyframes borderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
.success-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: bounceIn 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55);
filter: drop-shadow(0 0 20px rgba(0, 200, 255, 0.5));
}
@keyframes bounceIn {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
h1 {
color: #fff;
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.75rem 0;
background: linear-gradient(90deg, #fff, #00d4ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
margin: 0 0 2rem 0;
line-height: 1.6;
}
.highlight { color: #00d4ff; font-weight: 600; }
.sparkle {
position: absolute;
width: 4px;
height: 4px;
background: #00d4ff;
border-radius: 50%;
animation: sparkle 2s ease-in-out infinite;
}
.sparkle:nth-child(1) { top: 20%; left: 10%; animation-delay: 0s; }
.sparkle:nth-child(2) { top: 30%; right: 15%; animation-delay: 0.5s; }
.sparkle:nth-child(3) { bottom: 25%; left: 20%; animation-delay: 1s; }
.sparkle:nth-child(4) { bottom: 35%; right: 10%; animation-delay: 1.5s; }
@keyframes sparkle {
0%, 100% { opacity: 0; transform: scale(0); }
50% { opacity: 1; transform: scale(1); }
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="grid"></div>
<div class="container">
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="success-icon">🎉</div>
<h1>Welcome to PinMe</h1>
<p>You are now logged in! <span class="highlight">🚀</span><br>Return to your terminal to continue.</p>
</div>
</body>
</html>`;
}
private getErrorHtml(error: string): string {
const encodedError = encodeURIComponent(error);
return `
<!DOCTYPE html>
<html>
<head>
<title>Login Failed - PinMe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #000;
overflow: hidden;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 20% 80%, rgba(255, 50, 50, 0.2) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(255, 100, 50, 0.2) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(100, 0, 50, 0.15) 0%, transparent 60%);
animation: bgPulse 6s ease-in-out infinite;
}
@keyframes bgPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.grid {
position: fixed;
top: 0;
left: 0;
width: 200%;
height: 200%;
background-image:
linear-gradient(rgba(255, 80, 80, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 80, 80, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
transform: perspective(500px) rotateX(60deg) translateY(-50%) translateZ(-200px);
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: perspective(500px) rotateX(60deg) translateY(0) translateZ(-200px); }
100% { transform: perspective(500px) rotateX(60deg) translateY(50px) translateZ(-200px); }
}
.container {
position: relative;
z-index: 10;
background: linear-gradient(135deg, rgba(40, 20, 20, 0.9) 0%, rgba(30, 10, 10, 0.95) 100%);
padding: 3.5rem 4rem;
border-radius: 32px;
box-shadow:
0 0 60px rgba(255, 50, 50, 0.15),
0 25px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(255, 50, 50, 0.1);
text-align: center;
border: 1px solid rgba(255, 50, 50, 0.2);
max-width: 440px;
backdrop-filter: blur(30px);
}
.container::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: 32px;
background: linear-gradient(135deg, rgba(255, 50, 50, 0.5), rgba(255, 150, 50, 0.5), rgba(150, 0, 50, 0.5));
z-index: -1;
opacity: 0.5;
animation: borderGlow 3s ease-in-out infinite;
}
@keyframes borderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
.error-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: shake 0.5s ease-in-out;
filter: drop-shadow(0 0 20px rgba(255, 50, 50, 0.5));
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-10px) rotate(-5deg); }
40% { transform: translateX(10px) rotate(5deg); }
60% { transform: translateX(-10px) rotate(-5deg); }
80% { transform: translateX(10px) rotate(5deg); }
}
h1 {
color: #fff;
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.75rem 0;
background: linear-gradient(90deg, #fff, #ff5050);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error {
color: #ff6b6b;
font-size: 1rem;
margin: 0 0 2rem 0;
padding: 1.25rem;
background: rgba(255, 50, 50, 0.1);
border-radius: 16px;
border: 1px solid rgba(255, 50, 50, 0.2);
font-weight: 500;
box-shadow: 0 0 20px rgba(255, 50, 50, 0.1);
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="grid"></div>
<div class="container">
<div class="error-icon">😵</div>
<h1>Oops!</h1>
<div class="error">${error}</div>
</div>
</body>
</html>`;
}
}
// Export singleton
export const webLoginManager = new WebLoginManager();
/* Stryker restore all */
/* c8 ignore stop */
// Legacy interface
export function setAuthToken(combined: string): AuthConfig {
const firstDash = combined.indexOf('-');
if (firstDash <= 0 || firstDash === combined.length - 1) {
throw new Error('Invalid token format. Expected "<address>-<jwt>".');
}
const address = combined.slice(0, firstDash).trim();
const token = combined.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Invalid token content. Address or token is empty.');
}
const config: AuthConfig = { address, token };
fs.ensureDirSync(CONFIG_DIR);
fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 });
return config;
}
export function getAuthConfig(): AuthConfig | null {
try {
if (!fs.existsSync(AUTH_FILE)) return null;
const data = fs.readJsonSync(AUTH_FILE) as AuthConfig;
if (!data?.address || !data?.token) return null;
return data;
} catch {
return null;
}
}
export function clearAuthToken(): void {
try {
if (fs.existsSync(AUTH_FILE)) {
fs.removeSync(AUTH_FILE);
}
} catch (error) {
console.error(`Failed to clear auth token: ${error}`);
}
}
export function getAuthHeaders(): Record<string, string> {
const conf = getAuthConfig();
if (!conf) {
throw new Error('Auth not set. Run: pinme login');
}
return {
'token-address': conf.address,
'authentication-tokens': conf.token,
};
}
export async function login(): Promise<AuthConfig> {
return webLoginManager.login();
}
export async function logout(): Promise<void> {
clearAuthToken();
console.log(chalk.green('Logged out successfully'));
}