Skip to content

Commit 3b59f08

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

11 files changed

Lines changed: 710 additions & 17 deletions

File tree

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: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ 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, isTokenExpired, saveCredentials } = require('./cliCredentials.ts');
1415

1516
const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
1617

@@ -56,15 +57,32 @@ function buildRequest() {
5657
return req;
5758
}
5859

60+
/**
61+
* Resolves the target URL from various sources.
62+
* @param {Object} req The request object.
63+
* @param {Object} allCredentials Stored credentials.
64+
* @returns {string|null} The resolved target URL.
65+
*/
66+
function resolveTarget(req, allCredentials) {
67+
return (
68+
req.target ||
69+
process.env.HARPER_CLI_TARGET ||
70+
process.env.CLI_TARGET ||
71+
(allCredentials && allCredentials.last_target)
72+
);
73+
}
74+
5975
/**
6076
* Using a unix domain socket will send a request to hdb operations API server
6177
* @param req
78+
* @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!
6279
* @returns {Promise<void>}
6380
*/
64-
async function cliOperations(req) {
65-
if (!req.target) {
66-
req.target = process.env.HARPER_CLI_TARGET || process.env.CLI_TARGET;
67-
}
81+
async function cliOperations(req, skipResponseLog = false) {
82+
require('dotenv').config();
83+
84+
const allCredentials = loadCredentials();
85+
req.target = resolveTarget(req, allCredentials);
6886
let target;
6987
if (req.target) {
7088
try {
@@ -76,15 +94,17 @@ async function cliOperations(req) {
7694
throw error;
7795
}
7896
}
97+
const resolvedTarget = `${target.protocol}//${target.hostname}${target.port ? ':' + target.port : ''}/`;
7998
target = {
8099
protocol: target.protocol,
81100
hostname: target.hostname,
82101
port: target.port,
83102
username: req.username || target.username || process.env.HARPER_CLI_USERNAME || process.env.CLI_TARGET_USERNAME,
84103
password: req.password || target.password || process.env.HARPER_CLI_PASSWORD || process.env.CLI_TARGET_PASSWORD,
85104
rejectUnauthorized: req.rejectUnauthorized,
105+
resolvedTarget,
86106
};
87-
console.error(`Connecting to ${target.protocol}//${target.hostname}:${target.port}`);
107+
console.error(`Connecting to ${resolvedTarget}`);
88108
} else {
89109
// if we aren't doing a targeted operation (like deploy), we initialize the config and verify that local harper
90110
// is running and that we can communicate with it.
@@ -110,6 +130,46 @@ async function cliOperations(req) {
110130
options.headers = { 'Content-Type': 'application/json' };
111131
if (target?.username) {
112132
options.headers.Authorization = `Basic ${Buffer.from(`${target.username}:${target.password}`).toString('base64')}`;
133+
} else if (allCredentials) {
134+
let tokens = null;
135+
let lookupKey = null;
136+
if (target && allCredentials.targets) {
137+
lookupKey = target.resolvedTarget.endsWith('/') ? target.resolvedTarget : target.resolvedTarget + '/';
138+
tokens =
139+
allCredentials.targets[lookupKey] ?? allCredentials.targets[target.resolvedTarget.replace(/\/$/, '')] ?? null;
140+
} else if (allCredentials.operation_token) {
141+
tokens = allCredentials;
142+
}
143+
144+
if (tokens?.operation_token) {
145+
if (tokens.refresh_token && isTokenExpired(tokens.operation_token)) {
146+
console.error('Operation token expired, attempting to refresh...');
147+
try {
148+
const refreshOptions = { ...options };
149+
refreshOptions.headers = { ...options.headers, Authorization: `Bearer ${tokens.refresh_token}` };
150+
const refreshResponse = await httpRequest(refreshOptions, {
151+
operation: 'refresh_operation_token',
152+
});
153+
if (refreshResponse.statusCode === 200) {
154+
const refreshData = JSON.parse(refreshResponse.body);
155+
if (refreshData.operation_token) {
156+
tokens.operation_token = refreshData.operation_token;
157+
saveCredentials({
158+
target: lookupKey || target?.resolvedTarget,
159+
operation_token: tokens.operation_token,
160+
refresh_token: tokens.refresh_token,
161+
});
162+
console.error('Operation token refreshed successfully.');
163+
}
164+
} else {
165+
console.error(`Failed to refresh operation token: ${refreshResponse.statusCode}`);
166+
}
167+
} catch (refreshErr) {
168+
console.error(`Error refreshing operation token: ${refreshErr.message}`);
169+
}
170+
}
171+
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
172+
}
113173
}
114174
if (req.cborEncode) {
115175
options.headers['Content-Type'] = 'application/cbor';
@@ -141,7 +201,13 @@ async function cliOperations(req) {
141201
process.exit(1);
142202
}
143203

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

146212
return responseData;
147213
} 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)