local-auth-cli.mjs
196 lines 5.8 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Phase 8 P1b-b — CLI helpers for auth subcommands (§4, §8).
3 */
4
5 import readline from 'readline';
6 import { loadConfig } from '../../lib/config.mjs';
7 import {
8 generateSetupToken,
9 bootstrapAdminCli,
10 pruneExpiredBootstrapRecord,
11 } from './local-auth-bootstrap.mjs';
12 import { issueCliLocalToken } from './local-auth.mjs';
13 import { readOfflineLockedAuthEnvGate, resolveOfflineLockedAuthPosture } from './local-auth-gate.mjs';
14
15 /**
16 * Prompt for passphrase on TTY (muted — not echoed, §4.2).
17 * @param {string} prompt
18 * @returns {Promise<string>}
19 */
20 export function promptPassphraseTTY(prompt = 'Passphrase: ') {
21 return new Promise((resolve, reject) => {
22 if (!process.stdin.isTTY) {
23 reject(new Error('Passphrase must be entered on a TTY (not piped)'));
24 return;
25 }
26 const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
27 process.stdout.write(prompt);
28 process.stdin.setRawMode(true);
29 process.stdin.resume();
30 let value = '';
31 const onData = (buf) => {
32 const c = buf.toString('utf8');
33 for (const ch of c) {
34 if (ch === '\n' || ch === '\r' || ch === '\u0004') {
35 process.stdin.setRawMode(false);
36 process.stdin.removeListener('data', onData);
37 process.stdout.write('\n');
38 rl.close();
39 resolve(value);
40 return;
41 }
42 if (ch === '\u0003') {
43 process.stdin.setRawMode(false);
44 process.stdin.removeListener('data', onData);
45 rl.close();
46 reject(new Error('Cancelled'));
47 return;
48 }
49 if (ch === '\u007f' || ch === '\b') {
50 value = value.slice(0, -1);
51 } else {
52 value += ch;
53 }
54 }
55 };
56 process.stdin.on('data', onData);
57 });
58 }
59
60 /**
61 * Resolve data dir from config.
62 * @returns {string}
63 */
64 export function resolveCliDataDir() {
65 const config = loadConfig();
66 return config.data_dir;
67 }
68
69 /**
70 * Parse --expires-in for CLI token (§8.2), capped at 168h.
71 * @param {string|null} raw
72 * @returns {string}
73 */
74 export function parseTokenExpiresIn(raw) {
75 if (!raw) return '24h';
76 const s = String(raw).trim();
77 const m = s.match(/^(\d+)(m|h|d)$/i);
78 if (!m) throw new Error('--expires-in must be like 24h, 7d, or 30m');
79 const n = parseInt(m[1], 10);
80 const unit = m[2].toLowerCase();
81 let hours = unit === 'd' ? n * 24 : unit === 'h' ? n : n / 60;
82 if (hours > 168) throw new Error('--expires-in capped at 168h (7 days)');
83 if (hours < 1 / 60) throw new Error('--expires-in too short');
84 return s;
85 }
86
87 /**
88 * @param {string[]} args - argv slice after `auth`
89 * @returns {Promise<number>} exit code
90 */
91 export async function runAuthCli(args) {
92 const sub = args[0];
93 const getOpt = (name) => {
94 const i = args.indexOf('--' + name);
95 if (i === -1) return null;
96 const v = args[i + 1];
97 if (v == null || v.startsWith('--')) return null;
98 return v;
99 };
100 const shortOpt = (letter) => {
101 const i = args.indexOf('-' + letter);
102 if (i === -1) return null;
103 const v = args[i + 1];
104 if (v == null || v.startsWith('-')) return null;
105 return v;
106 };
107
108 let dataDir;
109 try {
110 dataDir = resolveCliDataDir();
111 } catch (e) {
112 console.error(e.message);
113 return 2;
114 }
115
116 pruneExpiredBootstrapRecord(dataDir);
117 const envGate = readOfflineLockedAuthEnvGate();
118 const { active: offlineLockedActive } = resolveOfflineLockedAuthPosture(envGate);
119 const sessionSecret =
120 process.env.SESSION_SECRET || process.env.HUB_JWT_SECRET || 'change-me-in-production';
121
122 if (sub === 'generate-setup-token') {
123 const username = getOpt('username') || 'admin';
124 const expiresIn = getOpt('expires-in') || '15m';
125 try {
126 const { token } = generateSetupToken(dataDir, username, expiresIn);
127 process.stdout.write(token + '\n');
128 return 0;
129 } catch (e) {
130 console.error(e.message === 'ALREADY_BOOTSTRAPPED' ? 'Already bootstrapped' : e.message);
131 return 1;
132 }
133 }
134
135 if (sub === 'bootstrap-admin') {
136 const username = getOpt('username') || 'admin';
137 try {
138 const passphrase = await promptPassphraseTTY('Passphrase: ');
139 const confirm = await promptPassphraseTTY('Confirm passphrase: ');
140 if (passphrase !== confirm) {
141 console.error('Passphrases do not match');
142 return 1;
143 }
144 const result = await bootstrapAdminCli(dataDir, username, passphrase);
145 console.log(JSON.stringify(result));
146 return 0;
147 } catch (e) {
148 const code = e.code || e.message;
149 console.error(code === 'WEAK_PASSPHRASE' ? 'Passphrase too weak' : e.message);
150 return 1;
151 }
152 }
153
154 if (sub === 'token') {
155 const username = getOpt('username') || shortOpt('u');
156 const vaultId = getOpt('vault-id') || shortOpt('v') || 'default';
157 const hubUrl =
158 getOpt('hub-url') ||
159 process.env.KNOWTATION_HUB_URL ||
160 process.env.HUB_BASE_URL ||
161 'http://localhost:3333';
162 let expiresIn;
163 try {
164 expiresIn = parseTokenExpiresIn(getOpt('expires-in'));
165 } catch (e) {
166 console.error(e.message);
167 return 1;
168 }
169 if (!username) {
170 console.error('auth token requires --username');
171 return 1;
172 }
173 try {
174 const passphrase = await promptPassphraseTTY('Passphrase: ');
175 const result = await issueCliLocalToken(dataDir, username, passphrase, {
176 sessionSecret,
177 jwtExpiry: expiresIn,
178 offlineLockedActive,
179 });
180 if (!result.ok) {
181 console.error(result.code);
182 return 1;
183 }
184 process.stdout.write(`KNOWTATION_HUB_URL=${hubUrl.replace(/\/$/, '')}\n`);
185 process.stdout.write(`KNOWTATION_HUB_TOKEN=${result.token}\n`);
186 process.stdout.write(`KNOWTATION_HUB_VAULT_ID=${vaultId}\n`);
187 return 0;
188 } catch (e) {
189 console.error(e.message);
190 return 1;
191 }
192 }
193
194 console.error('Usage: knowtation auth <generate-setup-token|bootstrap-admin|token> [options]');
195 return 1;
196 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago