@thinremote/thinr-cli
Version:
CLI for ThinRemote - Remote management for IoT devices
464 lines (400 loc) • 15.2 kB
JavaScript
import { Buffer } from 'node:buffer';
import { input, select } from '@inquirer/prompts';
import chalk from 'chalk';
import ora from 'ora';
import open, { apps } from 'open';
import { writeConfig, readConfig, mergeConfig } from './config.js';
import api, { setBaseURL } from './api.js';
/**
* Authenticate user
* @returns {Promise<void>}
*/
export async function authenticate() {
// Ask for authentication method
const method = await select({
message: 'How would you like to authenticate?',
choices: [
{ name: 'OAuth', value: 'oauth' },
{ name: 'Token', value: 'token' },
{ name: 'Username and password', value: 'userpass' },
]
});
// Ask for server URL
const server = await input({
message: 'Enter server URL:',
default: 'console.thinr.io',
validate: (input) => {
return input ? true : 'Server URL is required';
}
});
setBaseURL(`https://${server}`);
writeConfig({
server,
});
if (method === 'userpass') {
// Authenticate with username and password
await authenticateWithUserPass();
} else if (method === 'oauth') {
await authenticateWithOAuth();
} else {
// Authenticate with token
await authenticateWithToken();
}
}
/**
* Authenticate with username and password using OAuth2 password flow
* @returns {Promise<void>}
*/
async function authenticateWithUserPass() {
try {
// Ask for credentials
const username = await input({
message: 'Enter username:',
validate: (input) => {
return input ? true : 'Username is required';
}
});
const password = await input({
message: 'Enter password:',
type: 'password',
mask: '*',
validate: (input) => {
return input ? true : 'Password is required';
}
});
// Show spinner
const spinner = ora('Authenticating...').start();
try {
// Prepare OAuth2 password grant form data
const formData = new URLSearchParams();
formData.append('grant_type', 'password');
formData.append('username', username);
formData.append('password', password);
// Authenticate with server using OAuth2 password flow
const response = await api.post(
`/oauth/token`,
formData,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
// Update spinner message
spinner.text = 'Preparing user token...';
// Delete existing token if it exists
try {
await api.delete(
`/v1/users/${username}/tokens/ThinRemote`,
);
} catch (error) {
// Ignore 404 errors (token doesn't exist)
if (error.response && error.response.status !== 404) {
throw error;
}
}
// Create new token
spinner.text = 'Creating new user token...';
const createTokenResponse = await api.post(
`/v1/users/${username}/tokens`,
{
"enabled": true,
"allow": {"*": {"*": "*"}},
"deny": {},
"token": "ThinRemote",
"name": "ThinRemote",
"description": ""
},
{
headers: {
'Content-Type': 'application/json'
}
}
);
const permanentToken = createTokenResponse.data.access_token;
// Save configuration with permanent token
writeConfig({
username,
server,
token: permanentToken
});
spinner.succeed('Authentication successful');
} catch (error) {
spinner.fail('Authentication failed');
if (error.response && error.response.status === 401) {
console.error(chalk.red('Invalid username or password'));
} else if (error.response && error.response.status === 404) {
console.error(chalk.red('Authentication endpoint not found. Please check the server URL.'));
} else {
console.error(chalk.red(`Error: ${error.message}`));
}
process.exit(1);
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
}
export async function refreshToken() {
try {
const config = readConfig();
if (!config || !config.username || !config.server || !config.refresh_token) {
console.log(chalk.yellow('No configuration found'));
return;
}
//const spinner = ora('Refreshing user token...').start();
try {
// Prepare OAuth2 refresh token form data
const formData = new URLSearchParams();
formData.append('grant_type', 'refresh_token');
formData.append('client_id', '3b40164d28730e416cbd');
formData.append('refresh_token', config.refresh_token);
// Refresh token using OAuth2 refresh flow
const response = await api.post(
`/oauth/token`,
formData,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
// Save new token in configuration
mergeConfig({
token: response.data.access_token,
refresh_token: response.data.refresh_token
});
//spinner.succeed('Token refreshed successfully');
return response.data.access_token;
} catch (error) {
//spinner.fail('Failed to refresh token');
console.error(chalk.red(`Error: ${error.message}`));
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
}
/**
* Delete user token from server
* @returns {Promise<void>}
*/
export async function deleteToken() {
try {
const config = readConfig();
if (!config || !config.username || !config.server || !config.token) {
console.log(chalk.yellow('No configuration found'));
return;
}
const spinner = ora('Deleting user token...').start();
try {
await api.delete(
`/v1/users/${config.username}/tokens/ThinRemote`,
);
spinner.succeed('Token deleted successfully');
} catch (error) {
if (error.response && error.response.status === 404) {
spinner.succeed('Token was already deleted');
} else {
spinner.fail('Failed to delete token');
console.error(chalk.red(`Error: ${error.message}`));
}
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
}
/**
* Authenticate with token
* @returns {Promise<void>}
*/
async function authenticateWithToken() {
try {
// Ask for username and token
const username = await input({
message: 'Enter username:',
validate: (input) => {
return input ? true : 'Username is required';
}
});
const token = await input({
message: 'Enter token:',
type: 'password',
mask: '*',
validate: (input) => {
return input ? true : 'Token is required';
}
});
// Show spinner
const spinner = ora('Validating token...').start();
try {
// Validate token by making a request to the proxies API
await api.get(
`/v1/proxies`,
);
// Save configuration
mergeConfig({
username,
token
});
spinner.succeed('Token validation successful');
} catch (error) {
spinner.fail('Token validation failed');
if (error.response && error.response.status === 401) {
console.error(chalk.red('Invalid token'));
} else {
console.error(chalk.red(`Error: ${error.message}`));
}
process.exit(1);
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
}
/**
* Authenticate with OAuth token
* @returns {Promise<void>}
*/
async function authenticateWithOAuth() {
const client_id = '3b40164d28730e416cbd';
let device_code = '';
let oauth_tokens = {};
try {
// Show spinner
const spinner = ora('Requesting OAuth User Code...').start();
try {
// Validate OAuth token by making a request to the proxies API
const { data } = await api.post(
`/oauth/device/authorize`,
{
client_id: client_id
},
{
headers: {
'content-type': 'application/x-www-form-urlencoded'
}
}
);
spinner.succeed('OAuth User Code received');
console.log('\nYour one time device code is: ' + chalk.bold(data.user_code));
console.log(chalk.blue('Press ' + chalk.bold('ENTER')) + ' to open your browser or submit your device code here: ' + data.verification_uri + '\n');
device_code = data.device_code;
let validated = false;
let pollDeviceIntervalId;
let interval = 5000; // Default polling interval
const controller = new AbortController();
function setPollingInterval(interval) {
if (pollDeviceIntervalId) {
clearInterval(pollDeviceIntervalId);
}
pollDeviceIntervalId = setInterval(pollDevice, interval);
}
async function pollDevice () {
api.post(
`/oauth/token`,
{
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: client_id,
device_code: device_code
},
{
headers: {
'content-type': 'application/x-www-form-urlencoded'
}
}
)
.then((response) => {
if (response.status === 200) {
console.log(response);
validated = true;
oauth_tokens = response.data;
controller.abort("validated");
}
}).catch((e) => {
if (e.status === 400) {
// Check for specific OAuth error messages
if ( e.response.data.error === 'authorization_pending' ) {
//console.debug("Authorization pending, continuing to poll...");
} else if ( e.response.data.error === 'slow_down' ) {
//console.debug("Received slow_down, increasing polling interval");
// Increase polling interval to avoid hitting rate limits
clearInterval(pollDeviceIntervalId);
interval = 5000+1000;
setPollingInterval(interval); // Increase to 10 seconds
} else if ( e.response.data.error === 'access_denied' ) {
console.error(chalk.red('User denied the authorization request'));
process.exit(1);
} else if ( e.response.data.error === 'expired_token' ) {
console.error(chalk.red('Device code expired'));
process.exit(1);
} else {
console.error(chalk.red(`OAuth error: ${e.response.data.error}`));
}
}
});
}
// Poll every 5 seconds
setPollingInterval(interval);
//pollDeviceIntervalId = setInterval(pollDevice, 5000, client_id, data.device_code);
// Timeout after 10 minutes
setTimeout(() => {
clearInterval(pollDeviceIntervalId)
controller.abort("timeout");
}, 600000);
// Wait for validation
await input({
message: 'Press ENTER to continue or wait for authentication...',
waitUserInput: true,
validate: function (input) {
open(data.verification_uri_complete, {app: [{name: apps.browser}, 'firefox-developer-edition']});
return '';
},
},
{
signal: controller.signal
}).catch((error) => {
if (error.cause === 'timeout') {
console.log(chalk.yellow('Polling aborted due to timeout'));
// Polling was aborted
} else if (error.cause === 'validated') {
console.log(chalk.green('Device authenticated successfully'));
}
});
clearInterval(pollDeviceIntervalId);
const token = oauth_tokens.access_token;
const refresh_token = oauth_tokens.refresh_token;
const decodedJWT = JSON.parse(Buffer.from(oauth_tokens.access_token.split('.')[1], 'base64').toString());
const username = decodedJWT.usr;
// Save configuration
mergeConfig({
username,
token,
refresh_token,
});
spinner.succeed('OAuth token validation successful\n');
} catch (error) {
spinner.fail('OAuth token validation failed');
if (error.response && error.response.status === 401) {
console.error(chalk.red('Invalid OAuth token'));
} else {
console.error(chalk.red(`Error: ${error.message}`));
}
process.exit(1);
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
}
/**
* Get access token from configuration
* @returns {string|axios.CancelToken|*|null}
*/
export function getAccessToken() {
const config = readConfig();
if (!config || !config.token) {
console.log(chalk.yellow('No token found in configuration'));
return null;
}
return config.token;
}