spck 0.3.1

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.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Firebase authentication with local callback server
3
+ * Opens browser for OAuth flow, captures token via localhost POST
4
+ *
5
+ * Security features:
6
+ * - Token sent via POST body (not in URL) to prevent leaking via browser history/referrer
7
+ * - State parameter for CSRF protection
8
+ * - Localhost-only callback server
9
+ */
10
+
11
+ import * as http from 'http';
12
+ import * as crypto from 'crypto';
13
+ import jwt from 'jsonwebtoken';
14
+ import open from 'open';
15
+ import qrcode from 'qrcode-terminal';
16
+ import { FirebaseCredentials, StoredCredentials } from '../types.js';
17
+ import { saveCredentials } from '../config/credentials.js';
18
+ import { logAuth } from '../utils/logger.js';
19
+ import { t } from '../i18n/index.js';
20
+ import { fetchServerList, selectBestServer, isValidDomain } from '../config/server-selection.js';
21
+
22
+ const AUTH_TIMEOUT = 10 * 60 * 1000; // 10 minutes
23
+ const FIREBASE_AUTH_BASE_URL = 'https://spck.io/auth';
24
+
25
+ // Module-level references to allow aborting auth from outside (e.g., SIGINT handler)
26
+ let _authAbortController: AbortController | null = null;
27
+ let _authCallbackServer: http.Server | null = null;
28
+
29
+ /**
30
+ * Abort any in-progress authentication (e.g., on SIGINT).
31
+ * Cancels the pending fetch and closes the local callback server.
32
+ */
33
+ export function abortCurrentAuth(): void {
34
+ _authAbortController?.abort();
35
+ _authCallbackServer?.close();
36
+ _authAbortController = null;
37
+ _authCallbackServer = null;
38
+ }
39
+
40
+ /**
41
+ * Find an available port for the local callback server
42
+ */
43
+ async function getAvailablePort(): Promise<number> {
44
+ return new Promise((resolve, reject) => {
45
+ const server = http.createServer();
46
+
47
+ server.listen(0, () => {
48
+ const address = server.address();
49
+ const port = typeof address === 'string' ? 0 : address?.port || 0;
50
+
51
+ server.close(() => {
52
+ resolve(port);
53
+ });
54
+ });
55
+
56
+ server.on('error', reject);
57
+ });
58
+ }
59
+
60
+ interface TokenResult {
61
+ token: string;
62
+ refreshToken: string;
63
+ }
64
+
65
+ /**
66
+ * Long-poll proxy server for token (manual flow).
67
+ * The server holds the connection open until the browser posts the token (up to 30s),
68
+ * then returns 202 on timeout so we reconnect immediately — minimal round-trips.
69
+ */
70
+ async function pollServerForToken(serverUrl: string, code: string, signal: AbortSignal): Promise<TokenResult | null> {
71
+ while (!signal.aborted) {
72
+ try {
73
+ const response = await fetch(`https://${serverUrl}/api/auth/token/poll`, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ code }),
77
+ signal
78
+ });
79
+
80
+ if (response.status === 202) {
81
+ // Server hold timed out — reconnect immediately (no sleep needed)
82
+ continue;
83
+ }
84
+
85
+ if (!response.ok) {
86
+ // Brief pause on unexpected errors before retrying
87
+ await new Promise(resolve => setTimeout(resolve, 3000));
88
+ continue;
89
+ }
90
+
91
+ const result = await response.json();
92
+ if (result.idToken && result.refreshToken) {
93
+ return { token: result.idToken, refreshToken: result.refreshToken };
94
+ }
95
+
96
+ // Unexpected successful response without tokens — brief pause
97
+ await new Promise(resolve => setTimeout(resolve, 1000));
98
+ } catch (error: any) {
99
+ if (error.name === 'AbortError') return null;
100
+ // Network error — wait before retrying
101
+ await new Promise(resolve => setTimeout(resolve, 3000));
102
+ }
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Parse POST body from request
110
+ */
111
+ function parsePostBody(req: http.IncomingMessage): Promise<Record<string, string>> {
112
+ return new Promise((resolve, reject) => {
113
+ let body = '';
114
+ req.on('data', chunk => { body += chunk.toString(); });
115
+ req.on('end', () => {
116
+ try {
117
+ // Try JSON first
118
+ if (req.headers['content-type']?.includes('application/json')) {
119
+ resolve(JSON.parse(body));
120
+ } else {
121
+ // Parse URL-encoded form data
122
+ const params = new URLSearchParams(body);
123
+ const result: Record<string, string> = {};
124
+ params.forEach((value, key) => { result[key] = value; });
125
+ resolve(result);
126
+ }
127
+ } catch (e) {
128
+ reject(e);
129
+ }
130
+ });
131
+ req.on('error', reject);
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Authenticate with Firebase using secure local callback server
137
+ * Token is received via POST to prevent exposure in URLs
138
+ */
139
+ export async function authenticateWithFirebase(): Promise<FirebaseCredentials> {
140
+ console.log('\n=== ' + t('auth.title') + ' ===\n');
141
+
142
+ // 1. Select best proxy server
143
+ const servers = await fetchServerList();
144
+ const { server: selectedServer } = await selectBestServer(servers);
145
+ const proxyServerUrl = selectedServer.url;
146
+
147
+ if (!isValidDomain(proxyServerUrl)) {
148
+ throw new Error(`Untrusted proxy server domain: ${proxyServerUrl}`);
149
+ }
150
+
151
+ // 2. Start local HTTP server
152
+ const port = await getAvailablePort();
153
+ console.log(t('auth.startingCallback', { port: String(port) }));
154
+
155
+ const callbackServer = http.createServer();
156
+
157
+ await new Promise<void>(resolve => {
158
+ callbackServer.listen(port, () => resolve());
159
+ });
160
+
161
+ const redirectUrl = `http://localhost:${port}/callback`;
162
+
163
+ // 3. Generate code for both browser and manual flows
164
+ const code = crypto.randomBytes(32).toString('hex');
165
+
166
+ // 4. Build auth URLs
167
+ // Browser flow: localhost callback
168
+ const browserUrl = new URL(FIREBASE_AUTH_BASE_URL);
169
+ browserUrl.searchParams.set('redirect', redirectUrl);
170
+ browserUrl.searchParams.set('state', code);
171
+
172
+ // Manual flow: proxy server relay (server param is hostname only, https:// is assumed)
173
+ const manualUrl = new URL(FIREBASE_AUTH_BASE_URL);
174
+ manualUrl.searchParams.set('code', code);
175
+ manualUrl.searchParams.set('server', proxyServerUrl);
176
+
177
+ // 5. Open browser (primary method)
178
+ console.log(t('auth.openingBrowser') + '\n');
179
+
180
+ try {
181
+ await open(browserUrl.toString());
182
+ } catch (error: any) {
183
+ console.warn('⚠️ ' + t('auth.couldNotOpenBrowser', { message: error.message }) + '\n');
184
+ }
185
+
186
+ // 6. Display fallback method
187
+ console.log(t('auth.manualAuthHint') + '\n');
188
+
189
+ // Display QR code
190
+ qrcode.generate(manualUrl.toString(), { small: true });
191
+
192
+ console.log('\n' + t('auth.visitUrl', { url: manualUrl.toString() }) + '\n');
193
+ console.log(t('auth.waiting') + '\n');
194
+
195
+ // 7. Wait for authentication from either source (browser or manual)
196
+ const abortController = new AbortController();
197
+ _authAbortController = abortController;
198
+ _authCallbackServer = callbackServer;
199
+
200
+ const result = await new Promise<FirebaseCredentials>((resolve, reject) => {
201
+ const timeout = setTimeout(() => {
202
+ abortController.abort();
203
+ callbackServer.close();
204
+ reject(new Error('Authentication timeout after 10 minutes'));
205
+ }, AUTH_TIMEOUT);
206
+
207
+ // Start polling server API (manual flow)
208
+ const serverPolling = pollServerForToken(proxyServerUrl, code, abortController.signal).then(token => {
209
+ if (token) {
210
+ abortController.abort();
211
+ callbackServer.close();
212
+ return token;
213
+ }
214
+ return null;
215
+ });
216
+
217
+ // Handle localhost callback (browser flow)
218
+ callbackServer.on('request', async (req, res) => {
219
+ // Add CORS headers for POST requests from browser
220
+ res.setHeader('Access-Control-Allow-Origin', '*');
221
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
222
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
223
+
224
+ // Handle preflight
225
+ if (req.method === 'OPTIONS') {
226
+ res.writeHead(204);
227
+ res.end();
228
+ return;
229
+ }
230
+
231
+ // Only handle callback path
232
+ if (!req.url || !req.url.startsWith('/callback')) {
233
+ res.writeHead(404);
234
+ res.end('Not found');
235
+ return;
236
+ }
237
+
238
+ // Parse request parameters
239
+ let receivedState: string | null = null;
240
+ let token: string | null = null;
241
+ let refreshToken: string | null = null;
242
+ let error: string | null = null;
243
+
244
+ try {
245
+ if (req.method === 'POST') {
246
+ const body = await parsePostBody(req);
247
+ receivedState = body.state || null;
248
+ token = body.token || null;
249
+ refreshToken = body.refreshToken || null;
250
+ error = body.error || null;
251
+ } else {
252
+ const url = new URL(req.url, `http://localhost:${port}`);
253
+ receivedState = url.searchParams.get('state');
254
+ token = url.searchParams.get('token');
255
+ refreshToken = url.searchParams.get('refreshToken');
256
+ error = url.searchParams.get('error');
257
+ }
258
+ } catch (parseError: any) {
259
+ res.writeHead(400, { 'Content-Type': 'application/json' });
260
+ res.end(JSON.stringify({ success: false, error: 'Failed to parse request' }));
261
+ reject(new Error(`Failed to parse request: ${parseError.message}`));
262
+ return;
263
+ }
264
+
265
+ // Verify state/code (CSRF protection)
266
+ if (receivedState !== code) {
267
+ res.writeHead(400, { 'Content-Type': 'application/json' });
268
+ res.end(JSON.stringify({ success: false, error: 'Invalid state parameter' }));
269
+ reject(new Error('Invalid state parameter'));
270
+ return;
271
+ }
272
+
273
+ // Check for error
274
+ if (error) {
275
+ res.writeHead(400, { 'Content-Type': 'application/json' });
276
+ res.end(JSON.stringify({ success: false, error }));
277
+ reject(new Error(error));
278
+ return;
279
+ }
280
+
281
+ // Check for token
282
+ if (!token) {
283
+ res.writeHead(400, { 'Content-Type': 'application/json' });
284
+ res.end(JSON.stringify({ success: false, error: 'No token received' }));
285
+ reject(new Error('No token received'));
286
+ return;
287
+ }
288
+
289
+ // Check for refresh token BEFORE decoding
290
+ if (!refreshToken) {
291
+ res.writeHead(400, { 'Content-Type': 'application/json' });
292
+ res.end(JSON.stringify({ success: false, error: 'No refresh token received' }));
293
+ reject(new Error('No refresh token received'));
294
+ return;
295
+ }
296
+
297
+ // Decode and validate token
298
+ let decoded: any;
299
+ try {
300
+ decoded = jwt.decode(token);
301
+
302
+ if (!decoded || typeof decoded === 'string') {
303
+ throw new Error('Invalid token format');
304
+ }
305
+
306
+ if (!decoded.sub && !decoded.uid) {
307
+ throw new Error('Token missing user ID');
308
+ }
309
+
310
+ if (!decoded.exp) {
311
+ throw new Error('Token missing expiry');
312
+ }
313
+ } catch (decodeError: any) {
314
+ res.writeHead(400, { 'Content-Type': 'application/json' });
315
+ res.end(JSON.stringify({ success: false, error: `Invalid token: ${decodeError.message}` }));
316
+ reject(new Error(`Invalid token: ${decodeError.message}`));
317
+ return;
318
+ }
319
+
320
+ // All validations passed - send success response
321
+ res.writeHead(200, { 'Content-Type': 'application/json' });
322
+ res.end(JSON.stringify({ success: true }));
323
+
324
+ // Clean up and resolve
325
+ clearTimeout(timeout);
326
+ abortController.abort(); // Stop server polling
327
+
328
+ const credentials: FirebaseCredentials = {
329
+ firebaseToken: token,
330
+ firebaseTokenExpiry: decoded.exp * 1000,
331
+ refreshToken,
332
+ userId: decoded.sub || decoded.uid
333
+ };
334
+
335
+ resolve(credentials);
336
+ });
337
+
338
+ callbackServer.on('error', (error) => {
339
+ clearTimeout(timeout);
340
+ abortController.abort();
341
+ reject(error);
342
+ });
343
+
344
+ // Handle server polling result (manual flow)
345
+ serverPolling.then(result => {
346
+ if (!result) return; // Aborted or timed out
347
+
348
+ try {
349
+ const { token, refreshToken } = result;
350
+
351
+ // Decode token to get userId and expiry
352
+ const decoded: any = jwt.decode(token);
353
+
354
+ if (!decoded || typeof decoded === 'string') {
355
+ throw new Error('Invalid token format');
356
+ }
357
+
358
+ if (!decoded.sub && !decoded.uid) {
359
+ throw new Error('Token missing user ID');
360
+ }
361
+
362
+ if (!decoded.exp) {
363
+ throw new Error('Token missing expiry');
364
+ }
365
+
366
+ clearTimeout(timeout);
367
+
368
+ const credentials: FirebaseCredentials = {
369
+ firebaseToken: token,
370
+ firebaseTokenExpiry: decoded.exp * 1000,
371
+ refreshToken,
372
+ userId: decoded.sub || decoded.uid
373
+ };
374
+
375
+ resolve(credentials);
376
+ } catch (error: any) {
377
+ reject(new Error(`Invalid token from server: ${error.message}`));
378
+ }
379
+ }).catch(error => {
380
+ clearTimeout(timeout);
381
+ abortController.abort();
382
+ callbackServer.close();
383
+ reject(error);
384
+ });
385
+ });
386
+
387
+ // 8. Close server and clear module-level references
388
+ callbackServer.close();
389
+ _authAbortController = null;
390
+ _authCallbackServer = null;
391
+
392
+ // 9. Save credentials
393
+ saveCredentials(result);
394
+
395
+ logAuth('firebase_auth_success', {
396
+ userId: result.userId,
397
+ method: 'firebase'
398
+ });
399
+
400
+ console.log('✅ ' + t('auth.success'));
401
+ console.log(' ' + t('auth.userId', { userId: result.userId }) + '\n');
402
+
403
+ return result;
404
+ }
405
+
406
+ // Firebase API key for token refresh
407
+ const FIREBASE_API_KEY = 'AIzaSyCFgtHhWiM-EdFBdiDw9ISHfcGOqbV3OCU';
408
+
409
+ /**
410
+ * Refresh Firebase ID token using refresh token
411
+ *
412
+ * Firebase Token Refresh Protocol:
413
+ * - Endpoint: https://securetoken.googleapis.com/v1/token?key={API_KEY}
414
+ * - Method: POST with application/x-www-form-urlencoded
415
+ * - Body: grant_type=refresh_token&refresh_token={REFRESH_TOKEN}
416
+ * - Response: { id_token, refresh_token, expires_in, token_type, user_id }
417
+ * - ID tokens expire after 1 hour (3600 seconds)
418
+ * - Refresh tokens are long-lived but can be revoked
419
+ *
420
+ * @see https://firebase.google.com/docs/reference/rest/auth#section-refresh-token
421
+ */
422
+ export async function refreshFirebaseToken(storedCredentials: StoredCredentials): Promise<FirebaseCredentials> {
423
+ if (!storedCredentials.refreshToken) {
424
+ console.log('⚠️ ' + t('auth.noRefreshToken'));
425
+ return authenticateWithFirebase();
426
+ }
427
+
428
+ try {
429
+ const response = await fetch(
430
+ `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`,
431
+ {
432
+ method: 'POST',
433
+ headers: {
434
+ 'Content-Type': 'application/x-www-form-urlencoded'
435
+ },
436
+ body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(storedCredentials.refreshToken)}`
437
+ }
438
+ );
439
+
440
+ if (!response.ok) {
441
+ const errorData = await response.json().catch(() => ({}));
442
+ const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
443
+
444
+ // Handle specific Firebase errors
445
+ if (errorMessage === 'TOKEN_EXPIRED' || errorMessage === 'INVALID_REFRESH_TOKEN') {
446
+ logAuth('token_refresh_failed', {
447
+ userId: storedCredentials.userId,
448
+ reason: errorMessage
449
+ }, 'warn');
450
+ console.log('⚠️ ' + t('auth.refreshTokenExpired'));
451
+ return authenticateWithFirebase();
452
+ }
453
+
454
+ throw new Error(`Token refresh failed: ${errorMessage}`);
455
+ }
456
+
457
+ const data = await response.json();
458
+
459
+ // Firebase returns: id_token, refresh_token, expires_in, token_type, user_id
460
+ const newToken = data.id_token;
461
+ const newRefreshToken = data.refresh_token;
462
+ const parsedExpiresIn = parseInt(data.expires_in, 10);
463
+ const expiresIn = isNaN(parsedExpiresIn) ? 3600 : parsedExpiresIn; // Default 1 hour
464
+ const userId = data.user_id;
465
+
466
+ if (!newToken) {
467
+ throw new Error('No ID token in refresh response');
468
+ }
469
+
470
+ // Build full credentials with fresh ID token
471
+ const fullCredentials: FirebaseCredentials = {
472
+ firebaseToken: newToken,
473
+ firebaseTokenExpiry: Date.now() + (expiresIn * 1000),
474
+ refreshToken: newRefreshToken || storedCredentials.refreshToken,
475
+ userId: userId || storedCredentials.userId
476
+ };
477
+
478
+ // Save only refreshToken + userId to disk (not the ephemeral ID token)
479
+ saveCredentials({
480
+ refreshToken: fullCredentials.refreshToken,
481
+ userId: fullCredentials.userId,
482
+ proxyServerUrl: storedCredentials.proxyServerUrl
483
+ });
484
+
485
+ return fullCredentials;
486
+ } catch (error: any) {
487
+ logAuth('token_refresh_error', {
488
+ userId: storedCredentials.userId,
489
+ error: error.message
490
+ }, 'error');
491
+ console.error(t('auth.tokenRefreshError', { message: error.message }));
492
+ console.log('⚠️ ' + t('auth.tokenRefreshFailed'));
493
+ return authenticateWithFirebase();
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Get a valid Firebase ID token by refreshing from stored credentials
499
+ * Always generates a fresh ID token using the refresh token
500
+ */
501
+ export async function getValidFirebaseToken(storedCredentials: StoredCredentials): Promise<FirebaseCredentials> {
502
+ console.log('🔄 ' + t('auth.generatingToken'));
503
+ return refreshFirebaseToken(storedCredentials);
504
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * HMAC message signing validation with replay attack prevention
3
+ */
4
+
5
+ import * as crypto from 'crypto';
6
+ import { JSONRPCRequest, ErrorCode, createRPCError } from '../types.js';
7
+
8
+ /**
9
+ * Nonce tracking to prevent replay attacks
10
+ * Stores nonces with their expiry timestamps
11
+ */
12
+ const seenNonces = new Map<string, number>();
13
+
14
+ /**
15
+ * Clean up expired nonces from the tracking map
16
+ */
17
+ function cleanExpiredNonces(): void {
18
+ const now = Date.now();
19
+ for (const [nonce, expiry] of seenNonces.entries()) {
20
+ if (expiry < now) {
21
+ seenNonces.delete(nonce);
22
+ }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Get statistics about nonce tracking (for testing/monitoring)
28
+ */
29
+ export function getNonceStats(): { total: number; active: number } {
30
+ const now = Date.now();
31
+ let active = 0;
32
+ for (const expiry of seenNonces.values()) {
33
+ if (expiry >= now) {
34
+ active++;
35
+ }
36
+ }
37
+ return { total: seenNonces.size, active };
38
+ }
39
+
40
+ /**
41
+ * Clear all tracked nonces (for testing)
42
+ */
43
+ export function clearNonces(): void {
44
+ seenNonces.clear();
45
+ }
46
+
47
+ /**
48
+ * Validate HMAC signature on JSON-RPC request
49
+ */
50
+ export function validateHMAC(message: JSONRPCRequest, signingKey: string): boolean {
51
+ if (!message.hmac || !message.timestamp) {
52
+ return false;
53
+ }
54
+
55
+ // Reconstruct the message that was signed (must match client's _computeHMAC)
56
+ // Client uses: const { timestamp, hmac, ...rest } = request
57
+ // So we need to include all fields except timestamp and hmac
58
+ const payload: any = {
59
+ jsonrpc: message.jsonrpc,
60
+ method: message.method,
61
+ params: message.params,
62
+ id: message.id,
63
+ nonce: message.nonce
64
+ };
65
+
66
+ // Include deviceId if present (client includes it)
67
+ if ('deviceId' in message) {
68
+ payload.deviceId = (message as any).deviceId;
69
+ }
70
+
71
+ const messageToSign = message.timestamp + JSON.stringify(payload);
72
+
73
+ // Compute HMAC
74
+ const expectedHmac = crypto
75
+ .createHmac('sha256', signingKey)
76
+ .update(messageToSign)
77
+ .digest('hex');
78
+
79
+ // Check lengths match before constant-time comparison
80
+ if (message.hmac.length !== expectedHmac.length) {
81
+ return false;
82
+ }
83
+
84
+ // Compare with provided HMAC (constant-time comparison)
85
+ try {
86
+ return crypto.timingSafeEqual(
87
+ Buffer.from(message.hmac),
88
+ Buffer.from(expectedHmac)
89
+ );
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Validate HMAC or throw error
97
+ */
98
+ export function requireValidHMAC(message: JSONRPCRequest, signingKey: string): void {
99
+ if (!validateHMAC(message, signingKey)) {
100
+ throw createRPCError(
101
+ ErrorCode.HMAC_VALIDATION_FAILED,
102
+ 'HMAC validation failed - message signature invalid or missing'
103
+ );
104
+ }
105
+
106
+ // Check timestamp is recent (within 2 minutes)
107
+ if (message.timestamp) {
108
+ const now = Date.now();
109
+ const age = now - message.timestamp;
110
+ const maxAge = 2 * 60 * 1000; // 2 minutes (reduced from 5 to mitigate replay attacks)
111
+
112
+ if (age > maxAge || age < -60000) {
113
+ // Allow 1 minute clock skew
114
+ throw createRPCError(
115
+ ErrorCode.HMAC_VALIDATION_FAILED,
116
+ 'Message timestamp too old or invalid',
117
+ { timestamp: message.timestamp, serverTime: now }
118
+ );
119
+ }
120
+ }
121
+
122
+ // Check nonce to prevent replay attacks
123
+ if (seenNonces.has(message.nonce)) {
124
+ throw createRPCError(
125
+ ErrorCode.HMAC_VALIDATION_FAILED,
126
+ 'Duplicate nonce detected - possible replay attack',
127
+ { nonce: message.nonce }
128
+ );
129
+ }
130
+
131
+ // Store nonce with expiry (2 minutes from now)
132
+ const maxAge = 2 * 60 * 1000;
133
+ seenNonces.set(message.nonce, Date.now() + maxAge);
134
+
135
+ // Clean up expired nonces periodically
136
+ if (seenNonces.size > 1000) {
137
+ cleanExpiredNonces();
138
+ }
139
+ }