Skip to content

Commit 49a3227

Browse files
committed
feat: harper login and logout
This saves jwts, per target, in your ~/.harperdb directory.
1 parent 7e0260e commit 49a3227

14 files changed

Lines changed: 949 additions & 9 deletions

bin/cliCredentials.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { getHomeDir } from '../utility/common_utils.ts';
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+
* Normalizes a target operations API URL to a canonical form (with trailing slash).
21+
*/
22+
export function normalizeTarget(target: string): string {
23+
if (!target) return target;
24+
let normalized = target;
25+
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
26+
normalized = 'https://' + normalized;
27+
}
28+
try {
29+
const url = new URL(normalized);
30+
if (!url.port && !normalized.includes(':', normalized.indexOf('://') + 3)) {
31+
url.port = '9925';
32+
}
33+
normalized = url.toString();
34+
} catch {
35+
// If it's not a valid URL yet, we'll let it be handled later or it will fail
36+
}
37+
if (!normalized.endsWith('/')) {
38+
normalized += '/';
39+
}
40+
return normalized;
41+
}
42+
43+
/**
44+
* Loads the JWT credentials from the ~/.harperdb/credentials.json file.
45+
*/
46+
export function loadCredentials(): TargetedCredentials {
47+
const credentialsFile = getCredentialsFile();
48+
try {
49+
return JSON.parse(fs.readFileSync(credentialsFile, 'utf8'));
50+
} catch (err) {
51+
if (err.code !== 'ENOENT') {
52+
throw new Error(`Error reading credentials file: ${err.message}`);
53+
}
54+
}
55+
return {
56+
last_target: null,
57+
targets: {},
58+
};
59+
}
60+
61+
/**
62+
* Saves the JWT credentials to the ~/.harperdb/credentials.json file.
63+
*/
64+
export function saveCredentials(target: string, tokens: Tokens): void {
65+
if (!target) {
66+
throw new Error('Target is required to save credentials.');
67+
}
68+
69+
target = normalizeTarget(target);
70+
const allCredentials = loadCredentials();
71+
72+
allCredentials.targets ||= {};
73+
allCredentials.targets[target] = tokens;
74+
allCredentials.last_target = target;
75+
76+
try {
77+
fs.mkdirSync(getCredentialsDir(), { recursive: true });
78+
fs.writeFileSync(getCredentialsFile(), JSON.stringify(allCredentials, null, 2), { mode: ownerRWDenyAllOthers });
79+
} catch (err) {
80+
throw new Error(`Error saving credentials file: ${err.message}`);
81+
}
82+
}
83+
84+
/**
85+
* Deletes the credentials for a specific target or all if no target provided.
86+
*/
87+
export function clearCredentials(target: string): void {
88+
const credentialsFile = getCredentialsFile();
89+
if (target) {
90+
target = normalizeTarget(target);
91+
const allCredentials = loadCredentials();
92+
if (allCredentials && allCredentials.targets) {
93+
if (allCredentials.targets[target]) {
94+
delete allCredentials.targets[target];
95+
} else {
96+
console.error(`No credentials found for ${target}`);
97+
process.exit(1);
98+
}
99+
100+
if (allCredentials.last_target === target) {
101+
allCredentials.last_target = null;
102+
}
103+
try {
104+
fs.writeFileSync(credentialsFile, JSON.stringify(allCredentials, null, 2), {
105+
mode: ownerRWDenyAllOthers,
106+
});
107+
console.log(`Logged out from ${target}`);
108+
} catch (err) {
109+
throw new Error(`Error clearing credentials file: ${err.message}`);
110+
}
111+
} else {
112+
console.error(`No credentials found for ${target}`);
113+
process.exit(1);
114+
}
115+
} else if (fs.existsSync(credentialsFile)) {
116+
try {
117+
fs.unlinkSync(credentialsFile);
118+
console.log('Logged out from all targets');
119+
} catch (err) {
120+
throw new Error(`Error clearing credentials file: ${err.message}`);
121+
}
122+
} else {
123+
console.log('No credentials found to clear.');
124+
}
125+
}
126+
127+
function getCredentialsFile(): string {
128+
return path.join(getHomeDir(), '.harperdb', 'credentials.json');
129+
}
130+
131+
function getCredentialsDir(): string {
132+
return path.join(getHomeDir(), '.harperdb');
133+
}

bin/cliOperations.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict';
22

3+
import { loadCredentials, saveCredentials, normalizeTarget } from './cliCredentials.ts';
4+
import { isJWTExpired } from '../security/tokenAuthentication.ts';
35
import * as envMgr from '../utility/environment/environmentManager.ts';
46
envMgr.initSync();
57
import * as terms from '../utility/hdbTerms.ts';
@@ -56,15 +58,32 @@ function buildRequest(): any {
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: any) {
65-
if (!req.target) {
66-
req.target = process.env.HARPER_CLI_TARGET || process.env.CLI_TARGET;
67-
}
82+
async function cliOperations(req: any, skipResponseLog = false) {
83+
require('dotenv').config();
84+
85+
const allCredentials = loadCredentials();
86+
req.target = normalizeTarget(resolveTarget(req, allCredentials));
6887
let target;
6988
if (req.target) {
7089
try {
@@ -76,15 +95,17 @@ async function cliOperations(req: any) {
7695
throw error;
7796
}
7897
}
98+
const resolvedTarget = req.target;
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,47 @@ async function cliOperations(req: any) {
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;
139+
tokens = allCredentials.targets[lookupKey] ?? null;
140+
}
141+
142+
if (tokens?.operation_token) {
143+
if (tokens.refresh_token && isJWTExpired(tokens.operation_token)) {
144+
console.error('Operation token expired, attempting to refresh...');
145+
try {
146+
const refreshOptions = { ...options };
147+
refreshOptions.headers = { ...options.headers, Authorization: `Bearer ${tokens.refresh_token}` };
148+
const refreshResponse = await httpRequest(refreshOptions, {
149+
operation: 'refresh_operation_token',
150+
});
151+
if (refreshResponse.statusCode === 200) {
152+
const refreshData = JSON.parse(refreshResponse.body);
153+
if (refreshData.operation_token) {
154+
tokens.operation_token = refreshData.operation_token;
155+
saveCredentials(lookupKey || target?.resolvedTarget, {
156+
operation_token: tokens.operation_token,
157+
refresh_token: tokens.refresh_token,
158+
});
159+
console.error('Operation token refreshed successfully.');
160+
// Update the original request's authorization header with the new token
161+
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
162+
}
163+
} else if (refreshResponse.statusCode === 401) {
164+
console.error('Refresh token expired or invalid. Please run harper login again.');
165+
process.exit(1);
166+
} else {
167+
console.error(`Failed to refresh operation token: ${refreshResponse.statusCode}`);
168+
}
169+
} catch (refreshErr) {
170+
console.error(`Error refreshing operation token: ${refreshErr.message}`);
171+
}
172+
}
173+
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
174+
}
113175
}
114176
if (req.cborEncode) {
115177
options.headers['Content-Type'] = 'application/cbor';
@@ -141,7 +203,13 @@ async function cliOperations(req: any) {
141203
process.exit(1);
142204
}
143205

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

146214
return responseData;
147215
} catch (err) {

bin/harper.ts

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').default || require('./status'))();
86+
case SERVICE_ACTIONS_ENUM.LOGIN: {
87+
const target = process.argv[3];
88+
const username = process.argv[4];
89+
const { login } = require('./login');
90+
return login(target, username);
91+
}
92+
case SERVICE_ACTIONS_ENUM.LOGOUT: {
93+
const target = process.argv[3];
94+
const { logout } = require('./logout');
95+
return logout(target);
96+
}
8497
case SERVICE_ACTIONS_ENUM.RENEWCERTS:
8598
return require('../security/keys')
8699
.renewSelfSigned()

0 commit comments

Comments
 (0)