bibliocanvas 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Authentication module for BiblioCanvas CLI
3
+ *
4
+ * All authentication is handled server-side via the BiblioCanvas API.
5
+ * No Firebase config or API keys are stored in the CLI.
6
+ */
7
+
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import * as http from 'node:http';
11
+ import * as crypto from 'node:crypto';
12
+
13
+ const CREDENTIALS_DIR = path.join(
14
+ process.env.HOME || process.env.USERPROFILE || '.',
15
+ '.bibliocanvas'
16
+ );
17
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
18
+
19
+ const API_URLS = {
20
+ production: 'https://bibliocanvas.web.app/api',
21
+ development: 'https://bibliocanvas-dev.web.app/api',
22
+ };
23
+
24
+ interface StoredCredentials {
25
+ refreshToken: string;
26
+ uid: string;
27
+ email: string;
28
+ displayName: string;
29
+ env: 'production' | 'development';
30
+ }
31
+
32
+ /**
33
+ * Get API base URL based on environment
34
+ */
35
+ export function getApiBaseUrl(env: 'production' | 'development'): string {
36
+ return API_URLS[env];
37
+ }
38
+
39
+ /**
40
+ * Load stored credentials
41
+ */
42
+ function loadCredentials(): StoredCredentials | null {
43
+ try {
44
+ if (fs.existsSync(CREDENTIALS_FILE)) {
45
+ const data = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
46
+ return JSON.parse(data);
47
+ }
48
+ } catch {
49
+ // ignore
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Save credentials to disk
56
+ */
57
+ function saveCredentials(creds: StoredCredentials): void {
58
+ if (!fs.existsSync(CREDENTIALS_DIR)) {
59
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
60
+ }
61
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), {
62
+ mode: 0o600,
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Delete stored credentials
68
+ */
69
+ export function deleteCredentials(): boolean {
70
+ try {
71
+ if (fs.existsSync(CREDENTIALS_FILE)) {
72
+ fs.unlinkSync(CREDENTIALS_FILE);
73
+ return true;
74
+ }
75
+ } catch {
76
+ // ignore
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Get a valid ID token (refresh via server-side API)
83
+ */
84
+ export async function getIdToken(env: 'production' | 'development' = 'production'): Promise<string> {
85
+ const creds = loadCredentials();
86
+ if (!creds) {
87
+ throw new Error('Not logged in. Run `bibliocanvas login` first.');
88
+ }
89
+
90
+ if (creds.env !== env) {
91
+ throw new Error(
92
+ `Logged in to ${creds.env} but trying to use ${env}. Run \`bibliocanvas login --dev\` or \`bibliocanvas login\`.`
93
+ );
94
+ }
95
+
96
+ // Refresh token via server-side API (no API key needed on client)
97
+ const baseUrl = getApiBaseUrl(env);
98
+ const response = await fetch(`${baseUrl}/auth/refresh`, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ refreshToken: creds.refreshToken }),
102
+ });
103
+
104
+ if (!response.ok) {
105
+ throw new Error('Token refresh failed. Run `bibliocanvas login` again.');
106
+ }
107
+
108
+ const data = await response.json();
109
+
110
+ // Update stored refresh token if it changed
111
+ if (data.refreshToken && data.refreshToken !== creds.refreshToken) {
112
+ creds.refreshToken = data.refreshToken;
113
+ saveCredentials(creds);
114
+ }
115
+
116
+ return data.idToken;
117
+ }
118
+
119
+ /**
120
+ * Get current user info from stored credentials
121
+ */
122
+ export function getCurrentUser(): { uid: string; email: string; displayName: string; env: string } | null {
123
+ const creds = loadCredentials();
124
+ if (!creds) return null;
125
+ return {
126
+ uid: creds.uid,
127
+ email: creds.email,
128
+ displayName: creds.displayName,
129
+ env: creds.env,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Login via browser OAuth flow (server-side token exchange)
135
+ *
136
+ * 1. Fetch OAuth client ID from server
137
+ * 2. Start local HTTP server
138
+ * 3. Open browser to Google OAuth consent screen
139
+ * 4. Receive authorization code via redirect
140
+ * 5. Send code to server for token exchange
141
+ * 6. Server returns Firebase tokens
142
+ * 7. Save refresh token to disk
143
+ */
144
+ export async function login(env: 'production' | 'development' = 'production'): Promise<{
145
+ email: string;
146
+ displayName: string;
147
+ }> {
148
+ const baseUrl = getApiBaseUrl(env);
149
+
150
+ // Fetch OAuth client ID from server (no secrets in CLI)
151
+ const configResponse = await fetch(`${baseUrl}/auth/config`);
152
+ if (!configResponse.ok) {
153
+ throw new Error('Failed to fetch OAuth config from server');
154
+ }
155
+ const config = await configResponse.json();
156
+ const clientId = config.clientId;
157
+
158
+ return new Promise((resolve, reject) => {
159
+ const server = http.createServer();
160
+ server.listen(0, '127.0.0.1', async () => {
161
+ const address = server.address();
162
+ if (!address || typeof address === 'string') {
163
+ reject(new Error('Failed to start local server'));
164
+ return;
165
+ }
166
+ const port = address.port;
167
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
168
+
169
+ // Generate state for CSRF protection
170
+ const state = crypto.randomBytes(16).toString('hex');
171
+
172
+ // Build Google OAuth URL
173
+ const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
174
+ authUrl.searchParams.set('client_id', clientId);
175
+ authUrl.searchParams.set('redirect_uri', redirectUri);
176
+ authUrl.searchParams.set('response_type', 'code');
177
+ authUrl.searchParams.set('scope', 'openid email profile');
178
+ authUrl.searchParams.set('state', state);
179
+ authUrl.searchParams.set('access_type', 'offline');
180
+ authUrl.searchParams.set('prompt', 'consent');
181
+
182
+ console.log(`\nOpening browser for Google login...`);
183
+ console.log(`If the browser doesn't open, visit:\n${authUrl.toString()}\n`);
184
+
185
+ // Open browser
186
+ const open = (await import('open')).default;
187
+ open(authUrl.toString()).catch(() => {
188
+ // Browser open failed, user can use the URL manually
189
+ });
190
+
191
+ // Handle the OAuth callback
192
+ server.on('request', async (req, res) => {
193
+ if (!req.url?.startsWith('/callback')) return;
194
+
195
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
196
+ const code = url.searchParams.get('code');
197
+ const returnedState = url.searchParams.get('state');
198
+
199
+ if (returnedState !== state) {
200
+ res.writeHead(400, { 'Content-Type': 'text/html' });
201
+ res.end('<h1>Error: Invalid state</h1>');
202
+ server.close();
203
+ reject(new Error('Invalid OAuth state'));
204
+ return;
205
+ }
206
+
207
+ if (!code) {
208
+ res.writeHead(400, { 'Content-Type': 'text/html' });
209
+ res.end('<h1>Error: No authorization code</h1>');
210
+ server.close();
211
+ reject(new Error('No authorization code received'));
212
+ return;
213
+ }
214
+
215
+ try {
216
+ // Send code to server for token exchange (no secrets on client)
217
+ const loginResponse = await fetch(`${baseUrl}/auth/login`, {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json' },
220
+ body: JSON.stringify({ code, redirectUri }),
221
+ });
222
+
223
+ if (!loginResponse.ok) {
224
+ const errorData = await loginResponse.json();
225
+ throw new Error(errorData.error || 'Authentication failed');
226
+ }
227
+
228
+ const loginData = await loginResponse.json();
229
+
230
+ // Save credentials (only refresh token, no API keys)
231
+ saveCredentials({
232
+ refreshToken: loginData.refreshToken,
233
+ uid: loginData.uid,
234
+ email: loginData.email || '',
235
+ displayName: loginData.displayName || '',
236
+ env,
237
+ });
238
+
239
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
240
+ res.end(`
241
+ <html>
242
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
243
+ <h1>✓ ログイン成功!</h1>
244
+ <p>${loginData.displayName || loginData.email} としてログインしました。</p>
245
+ <p>このウィンドウを閉じてください。</p>
246
+ </body>
247
+ </html>
248
+ `);
249
+
250
+ server.close();
251
+ resolve({
252
+ email: loginData.email || '',
253
+ displayName: loginData.displayName || '',
254
+ });
255
+ } catch (err) {
256
+ res.writeHead(500, { 'Content-Type': 'text/html' });
257
+ res.end(`<h1>Error: ${(err as Error).message}</h1>`);
258
+ server.close();
259
+ reject(err);
260
+ }
261
+ });
262
+
263
+ // Timeout after 2 minutes
264
+ setTimeout(() => {
265
+ server.close();
266
+ reject(new Error('Login timed out. Please try again.'));
267
+ }, 120000);
268
+ });
269
+ });
270
+ }