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