@thinremote/thinr-cli 1.0.0-alpha.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/lib/auth.js ADDED
@@ -0,0 +1,463 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { input, select } from '@inquirer/prompts';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import open, { apps } from 'open';
6
+ import { writeConfig, readConfig, mergeConfig } from './config.js';
7
+
8
+ import api, { setBaseURL } from './api.js';
9
+
10
+
11
+
12
+ /**
13
+ * Authenticate user
14
+ * @returns {Promise<void>}
15
+ */
16
+ export async function authenticate() {
17
+ // Ask for authentication method
18
+ const method = await select({
19
+ message: 'How would you like to authenticate?',
20
+ choices: [
21
+ { name: 'OAuth', value: 'oauth' },
22
+ { name: 'Token', value: 'token' },
23
+ { name: 'Username and password', value: 'userpass' },
24
+ ]
25
+ });
26
+
27
+ // Ask for server URL
28
+ const server = await input({
29
+ message: 'Enter server URL:',
30
+ default: 'console.thinr.io',
31
+ validate: (input) => {
32
+ return input ? true : 'Server URL is required';
33
+ }
34
+ });
35
+
36
+ setBaseURL(`https://${server}`);
37
+
38
+ writeConfig({
39
+ server,
40
+ });
41
+
42
+ if (method === 'userpass') {
43
+ // Authenticate with username and password
44
+ await authenticateWithUserPass();
45
+ } else if (method === 'oauth') {
46
+ await authenticateWithOAuth();
47
+ } else {
48
+ // Authenticate with token
49
+ await authenticateWithToken();
50
+ }
51
+
52
+ }
53
+
54
+ /**
55
+ * Authenticate with username and password using OAuth2 password flow
56
+ * @returns {Promise<void>}
57
+ */
58
+ async function authenticateWithUserPass() {
59
+ try {
60
+ // Ask for credentials
61
+ const username = await input({
62
+ message: 'Enter username:',
63
+ validate: (input) => {
64
+ return input ? true : 'Username is required';
65
+ }
66
+ });
67
+ const password = await input({
68
+ message: 'Enter password:',
69
+ type: 'password',
70
+ mask: '*',
71
+ validate: (input) => {
72
+ return input ? true : 'Password is required';
73
+ }
74
+ });
75
+
76
+ // Show spinner
77
+ const spinner = ora('Authenticating...').start();
78
+
79
+ try {
80
+ // Prepare OAuth2 password grant form data
81
+ const formData = new URLSearchParams();
82
+ formData.append('grant_type', 'password');
83
+ formData.append('username', username);
84
+ formData.append('password', password);
85
+
86
+ // Authenticate with server using OAuth2 password flow
87
+ const response = await api.post(
88
+ `/oauth/token`,
89
+ formData,
90
+ {
91
+ headers: {
92
+ 'Content-Type': 'application/x-www-form-urlencoded'
93
+ }
94
+ }
95
+ );
96
+
97
+ // Update spinner message
98
+ spinner.text = 'Preparing user token...';
99
+
100
+ // Delete existing token if it exists
101
+ try {
102
+ await api.delete(
103
+ `/v1/users/${username}/tokens/ThinRemote`,
104
+ );
105
+ } catch (error) {
106
+ // Ignore 404 errors (token doesn't exist)
107
+ if (error.response && error.response.status !== 404) {
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ // Create new token
113
+ spinner.text = 'Creating new user token...';
114
+ const createTokenResponse = await api.post(
115
+ `/v1/users/${username}/tokens`,
116
+ {
117
+ "enabled": true,
118
+ "allow": {"*": {"*": "*"}},
119
+ "deny": {},
120
+ "token": "ThinRemote",
121
+ "name": "ThinRemote",
122
+ "description": ""
123
+ },
124
+ {
125
+ headers: {
126
+ 'Content-Type': 'application/json'
127
+ }
128
+ }
129
+ );
130
+ const permanentToken = createTokenResponse.data.access_token;
131
+
132
+ // Save configuration with permanent token
133
+ writeConfig({
134
+ username,
135
+ server,
136
+ token: permanentToken
137
+ });
138
+
139
+ spinner.succeed('Authentication successful');
140
+ } catch (error) {
141
+ spinner.fail('Authentication failed');
142
+
143
+ if (error.response && error.response.status === 401) {
144
+ console.error(chalk.red('Invalid username or password'));
145
+ } else if (error.response && error.response.status === 404) {
146
+ console.error(chalk.red('Authentication endpoint not found. Please check the server URL.'));
147
+ } else {
148
+ console.error(chalk.red(`Error: ${error.message}`));
149
+ }
150
+
151
+ process.exit(1);
152
+ }
153
+ } catch (error) {
154
+ console.error(chalk.red(`Error: ${error.message}`));
155
+ process.exit(1);
156
+ }
157
+ }
158
+
159
+ export async function refreshToken() {
160
+ try {
161
+ const config = readConfig();
162
+ if (!config || !config.username || !config.server || !config.refresh_token) {
163
+ console.log(chalk.yellow('No configuration found'));
164
+ return;
165
+ }
166
+
167
+ //const spinner = ora('Refreshing user token...').start();
168
+
169
+ try {
170
+ // Prepare OAuth2 refresh token form data
171
+ const formData = new URLSearchParams();
172
+ formData.append('grant_type', 'refresh_token');
173
+ formData.append('client_id', '3b40164d28730e416cbd');
174
+ formData.append('refresh_token', config.refresh_token);
175
+
176
+ // Refresh token using OAuth2 refresh flow
177
+ const response = await api.post(
178
+ `/oauth/token`,
179
+ formData,
180
+ {
181
+ headers: {
182
+ 'Content-Type': 'application/x-www-form-urlencoded'
183
+ }
184
+ }
185
+ );
186
+
187
+ // Save new token in configuration
188
+ mergeConfig({
189
+ token: response.data.access_token,
190
+ refresh_token: response.data.refresh_token
191
+ });
192
+
193
+ //spinner.succeed('Token refreshed successfully');
194
+ return response.data.access_token;
195
+
196
+ } catch (error) {
197
+ //spinner.fail('Failed to refresh token');
198
+ console.error(chalk.red(`Error: ${error.message}`));
199
+ }
200
+ } catch (error) {
201
+ console.error(chalk.red(`Error: ${error.message}`));
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Delete user token from server
207
+ * @returns {Promise<void>}
208
+ */
209
+ export async function deleteToken() {
210
+ try {
211
+ const config = readConfig();
212
+ if (!config || !config.username || !config.server || !config.token) {
213
+ console.log(chalk.yellow('No configuration found'));
214
+ return;
215
+ }
216
+
217
+ const spinner = ora('Deleting user token...').start();
218
+
219
+ try {
220
+ await api.delete(
221
+ `/v1/users/${config.username}/tokens/ThinRemote`,
222
+ );
223
+ spinner.succeed('Token deleted successfully');
224
+ } catch (error) {
225
+ if (error.response && error.response.status === 404) {
226
+ spinner.succeed('Token was already deleted');
227
+ } else {
228
+ spinner.fail('Failed to delete token');
229
+ console.error(chalk.red(`Error: ${error.message}`));
230
+ }
231
+ }
232
+ } catch (error) {
233
+ console.error(chalk.red(`Error: ${error.message}`));
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Authenticate with token
239
+ * @returns {Promise<void>}
240
+ */
241
+ async function authenticateWithToken() {
242
+ try {
243
+ // Ask for username and token
244
+ const username = await input({
245
+ message: 'Enter username:',
246
+ validate: (input) => {
247
+ return input ? true : 'Username is required';
248
+ }
249
+ });
250
+ const token = await input({
251
+ message: 'Enter token:',
252
+ type: 'password',
253
+ mask: '*',
254
+ validate: (input) => {
255
+ return input ? true : 'Token is required';
256
+ }
257
+ });
258
+
259
+ // Show spinner
260
+ const spinner = ora('Validating token...').start();
261
+
262
+ try {
263
+ // Validate token by making a request to the proxies API
264
+ await api.get(
265
+ `/v1/proxies`,
266
+ );
267
+
268
+ // Save configuration
269
+ mergeConfig({
270
+ username,
271
+ token
272
+ });
273
+
274
+ spinner.succeed('Token validation successful');
275
+ } catch (error) {
276
+ spinner.fail('Token validation failed');
277
+
278
+ if (error.response && error.response.status === 401) {
279
+ console.error(chalk.red('Invalid token'));
280
+ } else {
281
+ console.error(chalk.red(`Error: ${error.message}`));
282
+ }
283
+
284
+ process.exit(1);
285
+ }
286
+ } catch (error) {
287
+ console.error(chalk.red(`Error: ${error.message}`));
288
+ process.exit(1);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Authenticate with OAuth token
294
+ * @returns {Promise<void>}
295
+ */
296
+ async function authenticateWithOAuth() {
297
+
298
+ const client_id = '3b40164d28730e416cbd';
299
+ let device_code = '';
300
+ let oauth_tokens = {};
301
+
302
+ try {
303
+
304
+ // Show spinner
305
+ const spinner = ora('Requesting OAuth User Code...').start();
306
+
307
+ try {
308
+ // Validate OAuth token by making a request to the proxies API
309
+ const { data } = await api.post(
310
+ `/oauth/device/authorize`,
311
+ {
312
+ client_id: client_id
313
+ },
314
+ {
315
+ headers: {
316
+ 'content-type': 'application/x-www-form-urlencoded'
317
+ }
318
+ }
319
+ );
320
+
321
+ spinner.succeed('OAuth User Code received');
322
+
323
+ console.log('\nYour one time device code is: ' + chalk.bold(data.user_code));
324
+
325
+ console.log(chalk.blue('Press ' + chalk.bold('ENTER')) + ' to open your browser or submit your device code here: ' + data.verification_uri + '\n');
326
+
327
+ device_code = data.device_code;
328
+
329
+ let validated = false;
330
+ let pollDeviceIntervalId;
331
+ let interval = 5000; // Default polling interval
332
+ const controller = new AbortController();
333
+
334
+ function setPollingInterval(interval) {
335
+ if (pollDeviceIntervalId) {
336
+ clearInterval(pollDeviceIntervalId);
337
+ }
338
+ pollDeviceIntervalId = setInterval(pollDevice, interval);
339
+ }
340
+
341
+ async function pollDevice () {
342
+ api.post(
343
+ `/oauth/token`,
344
+ {
345
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
346
+ client_id: client_id,
347
+ device_code: device_code
348
+ },
349
+ {
350
+ headers: {
351
+ 'content-type': 'application/x-www-form-urlencoded'
352
+ }
353
+ }
354
+ )
355
+ .then((response) => {
356
+ if (response.status === 200) {
357
+ console.log(response);
358
+ validated = true;
359
+ oauth_tokens = response.data;
360
+ controller.abort("validated");
361
+ }
362
+ }).catch((e) => {
363
+ if (e.status === 400) {
364
+ // Check for specific OAuth error messages
365
+ if ( e.response.data.error === 'authorization_pending' ) {
366
+ //console.debug("Authorization pending, continuing to poll...");
367
+ } else if ( e.response.data.error === 'slow_down' ) {
368
+ //console.debug("Received slow_down, increasing polling interval");
369
+ // Increase polling interval to avoid hitting rate limits
370
+ clearInterval(pollDeviceIntervalId);
371
+ interval = 5000+1000;
372
+ setPollingInterval(interval); // Increase to 10 seconds
373
+ } else if ( e.response.data.error === 'access_denied' ) {
374
+ console.error(chalk.red('User denied the authorization request'));
375
+ process.exit(1);
376
+ } else if ( e.response.data.error === 'expired_token' ) {
377
+ console.error(chalk.red('Device code expired'));
378
+ process.exit(1);
379
+ } else {
380
+ console.error(chalk.red(`OAuth error: ${e.response.data.error}`));
381
+ }
382
+ }
383
+ });
384
+
385
+ }
386
+
387
+ // Poll every 5 seconds
388
+ setPollingInterval(interval);
389
+ //pollDeviceIntervalId = setInterval(pollDevice, 5000, client_id, data.device_code);
390
+
391
+ // Timeout after 10 minutes
392
+ setTimeout(() => {
393
+ clearInterval(pollDeviceIntervalId)
394
+ controller.abort("timeout");
395
+ }, 600000);
396
+
397
+
398
+ // Wait for validation
399
+ await input({
400
+ message: 'Press ENTER to continue or wait for authentication...',
401
+ waitUserInput: true,
402
+ validate: function (input) {
403
+ open(data.verification_uri_complete, {app: [{name: apps.browser}, 'firefox-developer-edition']});
404
+ return '';
405
+ },
406
+ },
407
+ {
408
+ signal: controller.signal
409
+ }).catch((error) => {
410
+ if (error.cause === 'timeout') {
411
+ console.log(chalk.yellow('Polling aborted due to timeout'));
412
+ // Polling was aborted
413
+ } else if (error.cause === 'validated') {
414
+ console.log(chalk.green('Device authenticated successfully'));
415
+ }
416
+ });
417
+
418
+ clearInterval(pollDeviceIntervalId);
419
+
420
+ const token = oauth_tokens.access_token;
421
+ const refresh_token = oauth_tokens.refresh_token;
422
+ const decodedJWT = JSON.parse(Buffer.from(oauth_tokens.access_token.split('.')[1], 'base64').toString());
423
+ const username = decodedJWT.usr;
424
+
425
+ // Save configuration
426
+ mergeConfig({
427
+ username,
428
+ token,
429
+ refresh_token,
430
+ });
431
+
432
+ spinner.succeed('OAuth token validation successful\n');
433
+ } catch (error) {
434
+ spinner.fail('OAuth token validation failed');
435
+
436
+ if (error.response && error.response.status === 401) {
437
+ console.error(chalk.red('Invalid OAuth token'));
438
+ } else {
439
+ console.error(chalk.red(`Error: ${error.message}`));
440
+ }
441
+
442
+ process.exit(1);
443
+ }
444
+
445
+ } catch (error) {
446
+ console.error(chalk.red(`Error: ${error.message}`));
447
+ process.exit(1);
448
+ }
449
+
450
+ }
451
+
452
+ /**
453
+ * Get access token from configuration
454
+ * @returns {string|axios.CancelToken|*|null}
455
+ */
456
+ export function getAccessToken() {
457
+ const config = readConfig();
458
+ if (!config || !config.token) {
459
+ console.log(chalk.yellow('No token found in configuration'));
460
+ return null;
461
+ }
462
+ return config.token;
463
+ }
package/lib/config.js ADDED
@@ -0,0 +1,88 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ /**
6
+ * Get the config file path
7
+ * @returns {string} Config file path
8
+ */
9
+ export function getConfigPath() {
10
+ const homeDir = os.homedir();
11
+ const configDir = path.join(homeDir, '.config', 'thinr-cli');
12
+
13
+ // Create config directory if it doesn't exist
14
+ if (!fs.existsSync(configDir)) {
15
+ fs.mkdirSync(configDir, { recursive: true });
16
+ }
17
+
18
+ return path.join(configDir, 'config.json');
19
+ }
20
+
21
+ /**
22
+ * Check if configuration exists
23
+ * @returns {boolean} True if configuration exists
24
+ */
25
+ export function configExists() {
26
+ const configPath = getConfigPath();
27
+ return fs.existsSync(configPath);
28
+ }
29
+
30
+ /**
31
+ * Read configuration
32
+ * @returns {Object} Configuration object
33
+ */
34
+ export function readConfig() {
35
+ const configPath = getConfigPath();
36
+
37
+ if (!fs.existsSync(configPath)) {
38
+ return {};
39
+ }
40
+
41
+ try {
42
+ const configData = fs.readFileSync(configPath, 'utf8');
43
+ return JSON.parse(configData);
44
+ } catch (error) {
45
+ // If there's an error reading the config, return an empty object
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Write configuration
52
+ * @param {Object} config - Configuration object
53
+ */
54
+ export function writeConfig(config) {
55
+ const configPath = getConfigPath();
56
+ const configData = JSON.stringify(config, null, 2);
57
+
58
+ fs.writeFileSync(configPath, configData, 'utf8');
59
+ }
60
+
61
+ /**
62
+ * Merge new configuration properties with existing one
63
+ * @param {Object} newConfig - New configuration properties
64
+ */
65
+ export function mergeConfig(newConfig) {
66
+ const existingConfig = readConfig();
67
+ const mergedConfig = {...existingConfig, ...newConfig};
68
+ writeConfig(mergedConfig);
69
+ }
70
+
71
+ /**
72
+ * Delete configuration
73
+ * @returns {boolean} True if deleted successfully
74
+ */
75
+ export function deleteConfig() {
76
+ const configPath = getConfigPath();
77
+
78
+ if (fs.existsSync(configPath)) {
79
+ try {
80
+ fs.unlinkSync(configPath);
81
+ return true;
82
+ } catch (error) {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ return true; // Already doesn't exist
88
+ }