import http from 'http';
import { URL } from 'url';
import open from 'open';
import { TokenStorage, TokenInfo } from './token-storage.js';
/**
* ブラウザ認証の結果
*/
export interface BrowserAuthResult {
token: string;
email: string;
}
/**
* ブラウザ認証フロー(OAuth Authorization Code Flow)
*
* 1. ローカルでコールバックサーバーを起動
* 2. ブラウザでPlume APIの認証ページを開く
* 3. ユーザーがログイン
* 4. Plume APIがlocalhostにリダイレクト(認証コード付き)
* 5. 認証コードをトークンに交換
* 6. トークンを保存
*/
export class BrowserAuth {
private tokenStorage: TokenStorage;
private port: number;
private baseUrl: string;
constructor(
baseUrl: string,
port: number = 34521,
tokenStorage?: TokenStorage
) {
this.baseUrl = baseUrl;
this.port = port;
this.tokenStorage = tokenStorage ?? new TokenStorage();
}
/**
* ブラウザ認証フローを開始
*/
async authenticate(): Promise<BrowserAuthResult> {
console.error('🔐 VergeCMS 認証を開始します...');
console.error(`📱 ブラウザでログインしてください`);
console.error('');
return new Promise((resolve, reject) => {
let server: http.Server | null = null;
const timeout = setTimeout(() => {
if (server) {
server.close();
}
reject(new Error('認証がタイムアウトしました (3分)'));
}, 3 * 60 * 1000); // 3分
// CSRFトークン生成
const state = crypto.randomUUID();
const redirectUri = `http://localhost:${this.port}/callback`;
server = http.createServer(async (req, res) => {
const url = new URL(req.url || '', `http://localhost:${this.port}`);
if (url.pathname === '/callback') {
// コールバック処理
await this.handleCallback(url, res, state, redirectUri, resolve, reject, timeout, server);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(this.port, () => {
console.error(`✅ ローカル認証サーバーを起動しました: http://localhost:${this.port}`);
console.error('');
// Plume APIの認証ページをブラウザで開く
const authUrl = new URL(`${this.baseUrl}/api/auth/oauth/authorize`);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('state', state);
console.error(`🌐 認証ページを開いています: ${authUrl.toString()}`);
console.error('');
open(authUrl.toString()).catch((err) => {
console.error('⚠️ ブラウザを自動で開けませんでした:', err.message);
console.error(` 手動で開いてください: ${authUrl.toString()}`);
});
});
server.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* コールバック処理
*/
private async handleCallback(
url: URL,
res: http.ServerResponse,
expectedState: string,
redirectUri: string,
resolve: (value: BrowserAuthResult) => void,
reject: (error: Error) => void,
timeout: NodeJS.Timeout,
server: http.Server | null
): Promise<void> {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// state検証(CSRF対策)
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.generateErrorPage('認証エラー', 'State mismatch - セキュリティエラーが発生しました'));
return;
}
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.generateErrorPage('認証エラー', '認証コードが見つかりません'));
return;
}
try {
// 認証コードをトークンに交換
const tokenResponse = await fetch(`${this.baseUrl}/api/auth/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
redirect_uri: redirectUri,
}),
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json().catch(() => ({}));
throw new Error((errorData as { error?: string }).error || 'トークン交換に失敗しました');
}
const tokenData = await tokenResponse.json() as {
token: string;
email: string;
expires_in?: number; // 有効期限(秒)
};
// 有効期限を計算(APIからexpires_inが返された場合はそれを使用、なければデフォルト7日)
const defaultExpiresInSeconds = 7 * 24 * 60 * 60; // 7日
const expiresInSeconds = tokenData.expires_in ?? defaultExpiresInSeconds;
const expiresAt = Date.now() + expiresInSeconds * 1000;
// トークンを保存
const tokenInfo: TokenInfo = {
token: tokenData.token,
baseUrl: this.baseUrl,
email: tokenData.email,
expiresAt,
};
await this.tokenStorage.saveToken(tokenInfo);
// 成功ページを表示
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.generateSuccessPage());
console.error('✅ 認証成功!');
console.error('');
// クリーンアップ
clearTimeout(timeout);
if (server) {
server.close();
}
// Promiseを解決
resolve({
token: tokenData.token,
email: tokenData.email,
});
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.generateErrorPage('認証エラー', error instanceof Error ? error.message : '不明なエラー'));
clearTimeout(timeout);
if (server) {
server.close();
}
reject(error instanceof Error ? error : new Error('認証に失敗しました'));
}
}
/**
* 成功ページHTMLを生成
*/
private generateSuccessPage(): string {
return `
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VergeCMS - 認証成功</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 60px 40px;
text-align: center;
max-width: 400px;
}
.icon { font-size: 64px; margin-bottom: 20px; }
h1 { color: #333; font-size: 28px; margin-bottom: 16px; }
p { color: #666; font-size: 16px; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<div class="icon">✅</div>
<h1>認証成功!</h1>
<p>
VergeCMSへの認証が完了しました。<br>
このウィンドウを閉じて、ターミナルに戻ってください。
</p>
</div>
<script>
setTimeout(() => window.close(), 5000);
</script>
</body>
</html>
`;
}
/**
* エラーページHTMLを生成
*/
private generateErrorPage(title: string, message: string): string {
return `
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VergeCMS - ${title}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #ff6b6b 0%, #c44569 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 60px 40px;
text-align: center;
max-width: 400px;
}
.icon { font-size: 64px; margin-bottom: 20px; }
h1 { color: #c33; font-size: 28px; margin-bottom: 16px; }
p { color: #666; font-size: 16px; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<div class="icon">❌</div>
<h1>${title}</h1>
<p>${message}</p>
</div>
</body>
</html>
`;
}
}