Skip to content

Commit 1efb3f6

Browse files
committed
feat: harper login and logout
This saves jwts, per target, in your ~/.harper directory.
1 parent 3a9ca34 commit 1efb3f6

12 files changed

Lines changed: 710 additions & 17 deletions

bin/cliCredentials.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { getHomeDir } from '#js/utility/common_utils';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
const ownerRWDenyAllOthers = 0o600;
6+
7+
interface TargetedCredentials {
8+
last_target: string | null;
9+
targets: {
10+
[target: string]: Tokens;
11+
};
12+
}
13+
14+
interface Tokens {
15+
operation_token: string;
16+
refresh_token: string;
17+
}
18+
19+
/**
20+
* Loads the JWT credentials from the ~/.harper/credentials.json file.
21+
*/
22+
export function loadCredentials(): TargetedCredentials {
23+
const credentialsFile = getCredentialsFile();
24+
try {
25+
return JSON.parse(fs.readFileSync(credentialsFile, 'utf8'));
26+
} catch (err) {
27+
if (err.code !== 'EEXIST') {
28+
console.error(`Error reading credentials file: ${err.message}`);
29+
}
30+
}
31+
return {
32+
last_target: null,
33+
targets: {},
34+
};
35+
}
36+
37+
/**
38+
* Saves the JWT credentials to the ~/.harper/credentials.json file.
39+
*/
40+
export function saveCredentials(target: string, tokens: Tokens): void {
41+
try {
42+
const allCredentials = loadCredentials();
43+
44+
if (target) {
45+
if (!target.endsWith('/')) {
46+
target += '/';
47+
}
48+
allCredentials.targets ||= {};
49+
allCredentials.targets[target] = tokens;
50+
allCredentials.last_target = target;
51+
} else {
52+
// Fallback for when target is not provided (shouldn't happen with new login)
53+
Object.assign(allCredentials, tokens);
54+
}
55+
56+
fs.mkdirSync(getCredentialsDir(), { recursive: true });
57+
fs.writeFileSync(getCredentialsFile(), JSON.stringify(allCredentials, null, 2), { mode: ownerRWDenyAllOthers });
58+
} catch (err) {
59+
console.error(`Error saving credentials file: ${err.message}`);
60+
}
61+
}
62+
63+
/**
64+
* Deletes the credentials for a specific target or all if no target provided.
65+
*/
66+
export function clearCredentials(target: string): void {
67+
const credentialsFile = getCredentialsFile();
68+
try {
69+
if (target) {
70+
if (!target.endsWith('/')) {
71+
target += '/';
72+
}
73+
const allCredentials = loadCredentials();
74+
if (allCredentials && allCredentials.targets) {
75+
if (allCredentials.targets[target]) {
76+
delete allCredentials.targets[target];
77+
} else {
78+
// try without trailing slash just in case
79+
const altTarget = target.slice(0, -1);
80+
delete allCredentials.targets[altTarget];
81+
}
82+
83+
if (allCredentials.last_target === target || allCredentials.last_target === target.replace(/\/$/, '')) {
84+
const remainingTargets = Object.keys(allCredentials.targets);
85+
allCredentials.last_target = remainingTargets.length > 0 ? remainingTargets[0] : null;
86+
}
87+
fs.writeFileSync(credentialsFile, JSON.stringify(allCredentials, null, 2), { mode: ownerRWDenyAllOthers });
88+
console.log(`Logged out from ${target}`);
89+
}
90+
} else if (fs.existsSync(credentialsFile)) {
91+
fs.unlinkSync(credentialsFile);
92+
console.log('Logged out from all targets');
93+
}
94+
} catch (err) {
95+
console.error(`Error clearing credentials file: ${err.message}`);
96+
}
97+
}
98+
99+
function getCredentialsFile(): string {
100+
return path.join(getHomeDir(), '.harper', 'credentials.json');
101+
}
102+
103+
function getCredentialsDir(): string {
104+
return path.join(getHomeDir(), '.harper');
105+
}

bin/cliOperations.js

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const { packageDirectory } = require('../components/packageComponent.ts');
1111
const { encode } = require('cbor-x');
1212
const { getHdbPid } = require('../utility/processManagement/processManagement.js');
1313
const { initConfig, getConfigPath } = require('../config/configUtils.js');
14+
const { loadCredentials, saveCredentials } = require('./cliCredentials.ts');
15+
const { isJWTExpired } = require('../security/tokenAuthentication.ts');
1416

1517
const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
1618

@@ -56,15 +58,32 @@ function buildRequest() {
5658
return req;
5759
}
5860

61+
/**
62+
* Resolves the target URL from various sources.
63+
* @param {Object} req The request object.
64+
* @param {Object} allCredentials Stored credentials.
65+
* @returns {string|null} The resolved target URL.
66+
*/
67+
function resolveTarget(req, allCredentials) {
68+
return (
69+
req.target ||
70+
process.env.HARPER_CLI_TARGET ||
71+
process.env.CLI_TARGET ||
72+
(allCredentials && allCredentials.last_target)
73+
);
74+
}
75+
5976
/**
6077
* Using a unix domain socket will send a request to hdb operations API server
6178
* @param req
79+
* @param skipResponseLog By default, the response is logged to the console. Set this to true to skip logging it, which can be useful for sensitive responses like login calls!
6280
* @returns {Promise<void>}
6381
*/
64-
async function cliOperations(req) {
65-
if (!req.target) {
66-
req.target = process.env.HARPER_CLI_TARGET || process.env.CLI_TARGET;
67-
}
82+
async function cliOperations(req, skipResponseLog = false) {
83+
require('dotenv').config();
84+
85+
const allCredentials = loadCredentials();
86+
req.target = resolveTarget(req, allCredentials);
6887
let target;
6988
if (req.target) {
7089
try {
@@ -76,15 +95,17 @@ async function cliOperations(req) {
7695
throw error;
7796
}
7897
}
98+
const resolvedTarget = `${target.protocol}//${target.hostname}${target.port ? ':' + target.port : ''}/`;
7999
target = {
80100
protocol: target.protocol,
81101
hostname: target.hostname,
82102
port: target.port,
83103
username: req.username || target.username || process.env.HARPER_CLI_USERNAME || process.env.CLI_TARGET_USERNAME,
84104
password: req.password || target.password || process.env.HARPER_CLI_PASSWORD || process.env.CLI_TARGET_PASSWORD,
85105
rejectUnauthorized: req.rejectUnauthorized,
106+
resolvedTarget,
86107
};
87-
console.error(`Connecting to ${target.protocol}//${target.hostname}:${target.port}`);
108+
console.error(`Connecting to ${resolvedTarget}`);
88109
} else {
89110
// if we aren't doing a targeted operation (like deploy), we initialize the config and verify that local harper
90111
// is running and that we can communicate with it.
@@ -110,6 +131,46 @@ async function cliOperations(req) {
110131
options.headers = { 'Content-Type': 'application/json' };
111132
if (target?.username) {
112133
options.headers.Authorization = `Basic ${Buffer.from(`${target.username}:${target.password}`).toString('base64')}`;
134+
} else if (allCredentials) {
135+
let tokens = null;
136+
let lookupKey = null;
137+
if (target && allCredentials.targets) {
138+
lookupKey = target.resolvedTarget.endsWith('/') ? target.resolvedTarget : target.resolvedTarget + '/';
139+
tokens =
140+
allCredentials.targets[lookupKey] ?? allCredentials.targets[target.resolvedTarget.replace(/\/$/, '')] ?? null;
141+
} else if (allCredentials.operation_token) {
142+
tokens = allCredentials;
143+
}
144+
145+
if (tokens?.operation_token) {
146+
if (tokens.refresh_token && isJWTExpired(tokens.operation_token)) {
147+
console.error('Operation token expired, attempting to refresh...');
148+
try {
149+
const refreshOptions = { ...options };
150+
refreshOptions.headers = { ...options.headers, Authorization: `Bearer ${tokens.refresh_token}` };
151+
const refreshResponse = await httpRequest(refreshOptions, {
152+
operation: 'refresh_operation_token',
153+
});
154+
if (refreshResponse.statusCode === 200) {
155+
const refreshData = JSON.parse(refreshResponse.body);
156+
if (refreshData.operation_token) {
157+
tokens.operation_token = refreshData.operation_token;
158+
saveCredentials({
159+
target: lookupKey || target?.resolvedTarget,
160+
operation_token: tokens.operation_token,
161+
refresh_token: tokens.refresh_token,
162+
});
163+
console.error('Operation token refreshed successfully.');
164+
}
165+
} else {
166+
console.error(`Failed to refresh operation token: ${refreshResponse.statusCode}`);
167+
}
168+
} catch (refreshErr) {
169+
console.error(`Error refreshing operation token: ${refreshErr.message}`);
170+
}
171+
}
172+
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
173+
}
113174
}
114175
if (req.cborEncode) {
115176
options.headers['Content-Type'] = 'application/cbor';
@@ -141,7 +202,13 @@ async function cliOperations(req) {
141202
process.exit(1);
142203
}
143204

144-
console.log(responseLog);
205+
if (!skipResponseLog) {
206+
console.log(responseLog);
207+
}
208+
209+
if (target) {
210+
responseData.resolvedTarget = target.resolvedTarget;
211+
}
145212

146213
return responseData;
147214
} catch (err) {

bin/harper.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ copy-db <source> <target> - Copies a database from source path to target p
2525
dev <path> - Run the application in dev mode with debugging, foreground logging, no auth
2626
install - Install harperdb
2727
<api-operation> <param>=<value> - Run an API operation and return result to the CLI, not all operations are supported
28+
login [target] [username] - Login to a remote or local Harper instance
29+
logout [target] - Logout from Harper and clear saved JWT
2830
register - Register harperdb
2931
renew-certs - Generate a new set of self-signed certificates
3032
restart - Restart the harperdb background process
@@ -81,6 +83,17 @@ async function harper() {
8183
.then(() => 'Your instance of Harper is up to date!');
8284
case SERVICE_ACTIONS_ENUM.STATUS:
8385
return require('./status.js')();
86+
case SERVICE_ACTIONS_ENUM.LOGIN: {
87+
const target = process.argv[3];
88+
const username = process.argv[4];
89+
const { login } = require('./login.ts');
90+
return login(target, username);
91+
}
92+
case SERVICE_ACTIONS_ENUM.LOGOUT: {
93+
const target = process.argv[3];
94+
const { logout } = require('./logout.ts');
95+
return logout(target);
96+
}
8497
case SERVICE_ACTIONS_ENUM.RENEWCERTS:
8598
return require('../security/keys.js')
8699
.renewSelfSigned()

0 commit comments

Comments
 (0)