@epure-sh/cli 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/auth.js CHANGED
@@ -1,469 +1,175 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const os = require('os');
4
- const http = require('http');
5
- const https = require('https');
6
- const crypto = require('crypto');
7
- const { exec } = require('child_process');
8
- const readline = require('readline');
9
-
10
- function getCredentialsPath() {
11
- const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
12
- return path.join(configHome, 'epure', 'credentials.json');
13
- }
14
-
1
+ 'use strict';
2
+ // Shared OAuth runtime. Keep identical in both npm packages (enforced in tests).
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const http = require('node:http');
7
+ const crypto = require('node:crypto');
8
+ const { spawn } = require('node:child_process');
9
+ const { setTimeout: sleep } = require('node:timers/promises');
10
+ const CLIENT_ID = 'epure-cli';
11
+ const SCOPES = ['read:agent', 'write:triage', 'write:admin'];
12
+ class ClientError extends Error {
13
+ constructor(code, message, exitCode = 1) { super(message); this.code = code; this.exitCode = exitCode; }
14
+ }
15
+ function authRequired() { return new ClientError('AUTH_REQUIRED', 'Sign in with: epure auth login (or epure-mcp login). Use --device on a remote machine.', 4); }
16
+ function normalizeUrl(value = 'https://app.epure.sh') {
17
+ const raw = value.trim();
18
+ let u; try { u = new URL(raw.includes('://') ? raw : `https://${raw}`); } catch { throw new ClientError('INVALID_URL', 'Use an HTTPS instance origin.', 2); }
19
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
20
+ if ((u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) || u.username || u.password || u.search || u.hash || !['', '/'].includes(u.pathname)) {
21
+ throw new ClientError('INVALID_URL', 'Use an HTTPS instance origin (HTTP is allowed only on loopback).', 2);
22
+ }
23
+ return u.origin;
24
+ }
25
+ function getCredentialsPath() { return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'epure', 'credentials.json'); }
15
26
  function readCredentials() {
16
- const p = getCredentialsPath();
17
- try {
18
- if (fs.existsSync(p)) {
19
- const raw = fs.readFileSync(p, 'utf8');
20
- return JSON.parse(raw);
21
- }
22
- } catch {}
23
- return { current_instance: '', instances: {} };
24
- }
25
-
26
- function writeCredentials(data) {
27
- const p = getCredentialsPath();
28
- const dir = path.dirname(p);
29
- if (!fs.existsSync(dir)) {
30
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
31
- }
32
- fs.writeFileSync(p, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
33
- try {
34
- fs.chmodSync(p, 0o600);
35
- } catch {}
36
- }
37
-
38
- function normalizeUrl(url) {
39
- if (!url) return 'https://app.epure.sh';
40
- let u = url.trim().replace(/\/+$/, '');
41
- if (!u.startsWith('http://') && !u.startsWith('https://')) {
42
- u = `https://${u}`;
43
- }
44
- return u;
45
- }
46
-
47
- function getActiveCredentials(targetUrl) {
48
- const creds = readCredentials();
49
- const normTarget = targetUrl ? normalizeUrl(targetUrl) : '';
50
- const current = normTarget || creds.current_instance || 'https://app.epure.sh';
51
- const instance = creds.instances ? creds.instances[current] : null;
52
- if (!instance && !normTarget && creds.instances) {
53
- const keys = Object.keys(creds.instances);
54
- if (keys.length > 0) {
55
- return { url: keys[0], ...creds.instances[keys[0]] };
56
- }
57
- }
58
- return instance ? { url: current, ...instance } : null;
59
- }
60
-
61
- function saveInstance(url, data) {
62
- const normUrl = normalizeUrl(url);
63
- const creds = readCredentials();
64
- if (!creds.instances) creds.instances = {};
65
- creds.instances[normUrl] = {
66
- url: normUrl,
67
- access_token: data.access_token,
68
- refresh_token: data.refresh_token,
69
- expires_at: data.expires_at,
70
- org_id: data.org_id,
71
- user_id: data.user_id,
72
- scopes: data.scopes || ['read:agent', 'write:triage'],
73
- };
74
- creds.current_instance = normUrl;
75
- writeCredentials(creds);
27
+ try { const c = JSON.parse(fs.readFileSync(getCredentialsPath(), 'utf8')); if (!c.instances || typeof c.instances !== 'object') throw Error(); return c; }
28
+ catch (e) { if (e.code === 'ENOENT') return { current_instance: '', instances: {} }; throw new ClientError('CREDENTIAL_STORE', 'Cannot read OAuth sessions. Repair or remove the credentials file, then sign in again.'); }
29
+ }
30
+ function writeCredentials(c) {
31
+ const dest = getCredentialsPath();
32
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
33
+ const tmp = `${dest}.${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
34
+ try { fs.writeFileSync(tmp, JSON.stringify(c, null, 2), { mode: 0o600, flag: 'wx' }); fs.renameSync(tmp, dest); }
35
+ finally { try { fs.unlinkSync(tmp); } catch {} }
36
+ }
37
+ function endpoint(value) { return normalizeUrl(value || process.env.EPURE_URL || readCredentials().current_instance || undefined); }
38
+ function getActiveCredentials(url) { const target = endpoint(url); const c = readCredentials().instances[target]; return c ? { ...c, url: target } : null; }
39
+ function saveInstance(url, data, activate = true) {
40
+ const target = normalizeUrl(url); const c = readCredentials();
41
+ c.instances[target] = { ...data, url: target };
42
+ if (activate) c.current_instance = target;
43
+ writeCredentials(c);
76
44
  }
77
-
78
- function switchInstance(url) {
79
- const normUrl = normalizeUrl(url);
80
- const creds = readCredentials();
81
- if (!creds.instances || !creds.instances[normUrl]) {
82
- throw new Error(`Instance "${normUrl}" not found in credentials. Run "epure login --url ${normUrl}" first.`);
83
- }
84
- creds.current_instance = normUrl;
85
- writeCredentials(creds);
86
- return normUrl;
87
- }
88
-
89
45
  function removeInstance(url) {
90
- const creds = readCredentials();
91
- const target = url ? normalizeUrl(url) : creds.current_instance;
92
- if (creds.instances && creds.instances[target]) {
93
- delete creds.instances[target];
94
- }
95
- if (creds.current_instance === target) {
96
- const remaining = Object.keys(creds.instances || {});
97
- creds.current_instance = remaining.length > 0 ? remaining[0] : '';
98
- }
99
- writeCredentials(creds);
100
- return target;
101
- }
102
-
103
- function listInstances() {
104
- const creds = readCredentials();
105
- return {
106
- current_instance: creds.current_instance || '',
107
- instances: Object.keys(creds.instances || {}),
108
- };
109
- }
110
-
111
- function base64url(buf) {
112
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
113
- }
114
-
115
- function openBrowser(url) {
116
- const platform = process.platform;
117
- let cmd;
118
- if (platform === 'darwin') {
119
- cmd = `open "${url}"`;
120
- } else if (platform === 'win32') {
121
- cmd = `start "" "${url}"`;
122
- } else {
123
- cmd = `xdg-open "${url}"`;
124
- }
125
- exec(cmd, () => {});
126
- }
127
-
128
- function postJson(fullUrl, body) {
129
- return new Promise((resolve, reject) => {
130
- const urlObj = new URL(fullUrl);
131
- const client = urlObj.protocol === 'https:' ? https : http;
132
- const payload = JSON.stringify(body);
133
-
134
- const req = client.request(
135
- fullUrl,
136
- {
137
- method: 'POST',
138
- headers: {
139
- 'Content-Type': 'application/json',
140
- 'Content-Length': Buffer.byteLength(payload),
141
- 'User-Agent': 'epure-auth/0.1.7',
142
- },
143
- },
144
- (res) => {
145
- let resBody = '';
146
- res.on('data', (c) => (resBody += c));
147
- res.on('end', () => {
148
- try {
149
- const data = JSON.parse(resBody);
150
- if (res.statusCode >= 200 && res.statusCode < 300) {
151
- resolve(data);
152
- } else {
153
- reject(new Error(data.error_description || data.error || `HTTP ${res.statusCode}: ${resBody}`));
154
- }
155
- } catch {
156
- if (res.statusCode >= 200 && res.statusCode < 300) {
157
- resolve({});
158
- } else {
159
- reject(new Error(`HTTP ${res.statusCode}: ${resBody}`));
160
- }
161
- }
162
- });
163
- }
164
- );
165
-
166
- req.on('error', reject);
167
- req.write(payload);
168
- req.end();
169
- });
170
- }
171
-
172
- async function ensureValidToken(instanceData, forceRefresh = false) {
173
- if (!instanceData || !instanceData.refresh_token) {
174
- return instanceData?.access_token || null;
175
- }
176
- const nowSec = Math.floor(Date.now() / 1000);
177
- const expiresAt = instanceData.expires_at || 0;
178
- // Refresh if under 48 hours left or forced
179
- if (!forceRefresh && expiresAt > nowSec + 172800) {
180
- return instanceData.access_token;
181
- }
182
-
46
+ const target = endpoint(url); const c = readCredentials(); delete c.instances[target];
47
+ if (c.current_instance === target) c.current_instance = Object.keys(c.instances)[0] || '';
48
+ writeCredentials(c); return target;
49
+ }
50
+ function switchInstance(url) { const target = normalizeUrl(url); const c = readCredentials(); if (!c.instances[target]?.refresh_token) throw authRequired(); c.current_instance = target; writeCredentials(c); return target; }
51
+ function listInstances() { const c = readCredentials(); return { current_instance: c.current_instance, instances: Object.keys(c.instances) }; }
52
+ async function post(url, body, signal) {
53
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 20000);
54
+ const abort = () => controller.abort(); signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
55
+ const json = url.endsWith('/oauth/device/code');
183
56
  try {
184
- const res = await postJson(`${instanceData.url}/oauth/token`, {
185
- grant_type: 'refresh_token',
186
- refresh_token: instanceData.refresh_token,
187
- });
188
- if (res.access_token) {
189
- instanceData.access_token = res.access_token;
190
- if (res.refresh_token) {
191
- instanceData.refresh_token = res.refresh_token;
192
- }
193
- instanceData.expires_at = nowSec + (res.expires_in || 2592000);
194
- saveInstance(instanceData.url, instanceData);
195
- return instanceData.access_token;
57
+ const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': json ? 'application/json' : 'application/x-www-form-urlencoded' }, body: json ? JSON.stringify(body) : new URLSearchParams(body), redirect: 'error', signal: controller.signal });
58
+ let data; try { data = await r.json(); } catch { data = {}; }
59
+ if (!r.ok) {
60
+ const known = ['authorization_pending', 'slow_down', 'access_denied', 'expired_token', 'invalid_grant', 'invalid_client', 'invalid_request', 'invalid_scope', 'server_error'];
61
+ const code = known.includes(data.error) ? data.error : 'OAUTH_ERROR';
62
+ throw new ClientError(code, `OAuth request failed (${code}). Run auth login again if approval expired.`, 4);
196
63
  }
64
+ return data;
197
65
  } catch (err) {
198
- if (expiresAt > nowSec) {
199
- return instanceData.access_token;
66
+ if (err instanceof ClientError) throw err;
67
+ throw new ClientError(signal?.aborted ? 'CANCELLED' : 'NETWORK_ERROR', signal?.aborted ? 'Sign-in cancelled.' : 'Cannot reach the OAuth service. Check your instance and connection.', signal?.aborted ? 2 : 1);
68
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
69
+ }
70
+ function persist(url, data, clientId = CLIENT_ID, activate = true) {
71
+ if (!data.access_token || !data.refresh_token || !Number.isFinite(Number(data.expires_in)) || Number(data.expires_in) <= 0) throw new ClientError('OAUTH_RESPONSE', 'The OAuth service returned an incomplete session.', 4);
72
+ const entry = { access_token: data.access_token, refresh_token: data.refresh_token, client_id: clientId, expires_at: Math.floor(Date.now() / 1000) + Number(data.expires_in), scopes: (data.scope || '').split(' ').filter(Boolean), org_id: data.org_id, user_id: data.user_id };
73
+ saveInstance(url, entry, activate); return entry;
74
+ }
75
+ // Serialize refresh rotation across CLI and MCP processes, not just within one process.
76
+ async function withLock(fn) {
77
+ const lock = `${getCredentialsPath()}.lock`;
78
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
79
+ const deadline = Date.now() + 25000; let fd;
80
+ while (fd === undefined) {
81
+ try { fd = fs.openSync(lock, 'wx', 0o600); fs.writeFileSync(fd, String(process.pid)); }
82
+ catch (e) {
83
+ if (e.code !== 'EEXIST') throw e;
84
+ try { const pid = Number(fs.readFileSync(lock, 'utf8')); if (pid > 0) { try { process.kill(pid, 0); } catch (p) { if (p.code === 'ESRCH') { fs.unlinkSync(lock); continue; } } } } catch (e2) { if (e2.code === 'ENOENT') continue; }
85
+ if (Date.now() >= deadline) throw new ClientError('AUTH_BUSY', 'Another process is refreshing the OAuth session. Retry shortly.');
86
+ await sleep(50);
200
87
  }
201
- throw err;
202
88
  }
203
- return instanceData.access_token;
204
- }
205
-
206
- function promptLine(query) {
207
- const rl = readline.createInterface({
208
- input: process.stdin,
209
- output: process.stdout,
210
- });
211
- return new Promise((resolve) => {
212
- rl.question(query, (ans) => {
213
- rl.close();
214
- resolve(ans.trim());
215
- });
89
+ try { return await fn(); } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
90
+ }
91
+ async function ensureValidToken(instance, force = false) {
92
+ if (!instance?.refresh_token || !instance.client_id) throw authRequired();
93
+ const fresh = c => c?.access_token && c.expires_at > Date.now() / 1000 + 300;
94
+ if (!force && fresh(instance)) return instance.access_token;
95
+ return withLock(async () => {
96
+ const current = getActiveCredentials(instance.url);
97
+ if (!current?.refresh_token) throw authRequired();
98
+ if (fresh(current) && (!force || current.access_token !== instance.access_token)) return current.access_token;
99
+ let res;
100
+ try { res = await post(`${instance.url}/oauth/token`, { grant_type: 'refresh_token', refresh_token: current.refresh_token, client_id: current.client_id || CLIENT_ID }); }
101
+ catch (err) { if (['invalid_grant', 'invalid_client', 'access_denied'].includes(err.code)) throw authRequired(); throw err; }
102
+ // Older servers may preserve the refresh credential instead of rotating it.
103
+ return persist(instance.url, { ...res, refresh_token: res.refresh_token || current.refresh_token }, current.client_id || CLIENT_ID, false).access_token;
216
104
  });
217
105
  }
218
-
219
- async function selectInstanceUrl(passedUrl) {
220
- if (passedUrl) {
221
- return normalizeUrl(passedUrl);
222
- }
223
-
224
- if (!process.stdin.isTTY) {
225
- return 'https://app.epure.sh';
226
- }
227
-
228
- console.log('\nSelect Epure instance:');
229
- console.log(' 1) Epure Cloud (https://app.epure.sh) [default]');
230
- console.log(' 2) Localhost (http://localhost:8080)');
231
- console.log(' 3) Custom Domain / VPS');
232
- const ans = await promptLine('Choose [1-3, default 1]: ');
233
-
234
- if (ans === '2') {
235
- return 'http://localhost:8080';
236
- } else if (ans === '3') {
237
- const custom = await promptLine('Enter custom instance URL (e.g. https://errors.mycompany.com): ');
238
- return normalizeUrl(custom || 'https://app.epure.sh');
106
+ function openBrowser(url) {
107
+ const args = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
108
+ const child = spawn(args[0], args[1], { stdio: 'ignore', detached: true }); child.on('error', () => {}); child.unref();
109
+ }
110
+ function publicSession(url) { const c = getActiveCredentials(url); return { instance: endpoint(url), authenticated: !!(c?.refresh_token && c?.client_id), user_id: c?.user_id, org_id: c?.org_id, scopes: c?.scopes, expires_at: c?.expires_at, login_command: 'epure auth login' }; }
111
+ async function loginDeviceCode(url, options = {}) {
112
+ const signal = options.signal; const log = options.log || (s => process.stderr.write(`${s}\n`));
113
+ const init = await post(`${url}/oauth/device/code`, { client_id: CLIENT_ID, client_name: 'Epure CLI and MCP', scope: options.scope || SCOPES.join(' ') }, signal);
114
+ const approval = new URL(init.verification_uri_complete || init.verification_uri, url);
115
+ if (approval.origin !== new URL(url).origin) throw new ClientError('OAUTH_RESPONSE', 'OAuth approval URL must belong to your instance.', 4);
116
+ if (!init.device_code || !init.user_code) throw new ClientError('OAUTH_RESPONSE', 'Missing device approval details.', 4);
117
+ log(`Approve sign-in: ${approval.href}\nCode: ${init.user_code}\nWaiting for browser approval…`);
118
+ if (!options.noBrowser) openBrowser(approval.href);
119
+ let interval = Math.max(1, Number(init.interval) || 5) * 1000;
120
+ const deadline = Date.now() + Math.min(Number(init.expires_in) || 600, 900) * 1000;
121
+ while (Date.now() + interval < deadline) {
122
+ await (options.sleep || sleep)(interval, undefined, { signal });
123
+ try {
124
+ const data = await post(`${url}/oauth/token`, { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: init.device_code, client_id: CLIENT_ID }, signal);
125
+ await withLock(async () => persist(url, data)); return publicSession(url);
126
+ } catch (e) { if (e.code === 'authorization_pending') continue; if (e.code === 'slow_down') { interval += 5000; continue; } throw e; }
239
127
  }
240
- return 'https://app.epure.sh';
128
+ throw new ClientError('expired_token', 'Approval expired. Run auth login --device again.', 4);
241
129
  }
242
-
243
- async function loginLoopback(targetUrl, clientId, clientName) {
130
+ async function loginLoopback(url, options = {}) {
244
131
  return new Promise((resolve, reject) => {
245
- const verifier = base64url(crypto.randomBytes(32));
246
- const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
247
- const state = crypto.randomBytes(16).toString('hex');
248
-
132
+ const verifier = crypto.randomBytes(32).toString('base64url');
133
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
134
+ const state = crypto.randomBytes(32).toString('hex'); let timer, handling = false;
135
+ const finish = (err, data) => { clearTimeout(timer); options.signal?.removeEventListener('abort', cancel); server.close(); server.closeIdleConnections?.(); err ? reject(err) : resolve(data); };
136
+ const cancel = () => finish(new ClientError('CANCELLED', 'Sign-in cancelled.', 2));
249
137
  const server = http.createServer(async (req, res) => {
138
+ const q = new URL(req.url, 'http://127.0.0.1');
139
+ if (q.pathname !== '/callback' || req.method !== 'GET') { res.writeHead(404).end(); return; }
140
+ if (q.searchParams.get('state') !== state) { res.writeHead(400).end('Invalid OAuth state. Return to the original sign-in tab.'); return; }
141
+ if (handling) { res.writeHead(409).end(); return; } handling = true;
250
142
  try {
251
- const parsed = new URL(req.url, 'http://127.0.0.1');
252
- if (parsed.pathname !== '/callback') {
253
- res.writeHead(404);
254
- res.end();
255
- return;
256
- }
257
-
258
- const code = parsed.searchParams.get('code');
259
- const returnedState = parsed.searchParams.get('state');
260
- const error = parsed.searchParams.get('error');
261
-
262
- if (error) {
263
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
264
- res.end(`
265
- <html><body style="font-family: sans-serif; background: #0c0d0e; color: #f87171; padding: 40px; text-align: center;">
266
- <h2>Authorization Denied</h2>
267
- <p>${parsed.searchParams.get('error_description') || error}</p>
268
- </body></html>
269
- `);
270
- server.close();
271
- return reject(new Error(`Authorization failed: ${error}`));
272
- }
273
-
274
- if (returnedState !== state) {
275
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
276
- res.end('State mismatch error.');
277
- server.close();
278
- return reject(new Error('OAuth state mismatch'));
279
- }
280
-
281
- if (!code) {
282
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
283
- res.end('Missing code parameter.');
284
- server.close();
285
- return reject(new Error('Missing authorization code'));
286
- }
287
-
288
- // Exchange code for tokens
289
- const port = server.address().port;
290
- const redirectUri = `http://127.0.0.1:${port}/callback`;
291
-
292
- const tokenRes = await postJson(`${targetUrl}/oauth/token`, {
293
- grant_type: 'authorization_code',
294
- code,
295
- code_verifier: verifier,
296
- redirect_uri: redirectUri,
297
- client_id: clientId,
298
- });
299
-
300
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
301
- res.end(`
302
- <!DOCTYPE html>
303
- <html>
304
- <head>
305
- <meta charset="utf-8">
306
- <title>Epure - Authenticated</title>
307
- <style>
308
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0c0d0e; color: #f0f0f0; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
309
- .box { background: #16181a; border: 1px solid #2a2d32; border-radius: 12px; padding: 40px; text-align: center; max-width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
310
- h1 { color: #fff; font-size: 20px; margin: 0 0 10px; }
311
- p { color: #888; font-size: 14px; margin: 0; }
312
- .icon { color: #10b981; font-size: 40px; margin-bottom: 16px; }
313
- </style>
314
- </head>
315
- <body>
316
- <div class="box">
317
- <div class="icon">✓</div>
318
- <h1>Authenticated with Epure</h1>
319
- <p>You can close this tab and return to your terminal.</p>
320
- </div>
321
- </body>
322
- </html>
323
- `);
324
-
325
- server.close();
326
-
327
- const nowSec = Math.floor(Date.now() / 1000);
328
- saveInstance(targetUrl, {
329
- access_token: tokenRes.access_token,
330
- refresh_token: tokenRes.refresh_token,
331
- expires_at: nowSec + (tokenRes.expires_in || 2592000),
332
- org_id: tokenRes.org_id,
333
- user_id: tokenRes.user_id,
334
- scopes: (tokenRes.scope || 'read:agent write:triage').split(' '),
335
- });
336
-
337
- resolve({
338
- url: targetUrl,
339
- org_id: tokenRes.org_id,
340
- user_id: tokenRes.user_id,
341
- });
342
- } catch (err) {
343
- server.close();
344
- reject(err);
345
- }
143
+ if (q.searchParams.has('error')) throw new ClientError('access_denied', 'Browser approval was denied.', 4);
144
+ const code = q.searchParams.get('code'); if (!code) throw new ClientError('OAUTH_RESPONSE', 'Missing authorization code.', 4);
145
+ const data = await post(`${url}/oauth/token`, { grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: `http://127.0.0.1:${server.address().port}/callback`, client_id: CLIENT_ID }, options.signal);
146
+ await withLock(async () => persist(url, data)); res.writeHead(200, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }).end('Signed in to Epure. You can close this tab.'); finish(null, publicSession(url));
147
+ } catch (e) { res.writeHead(400).end('Sign-in failed. Return to your terminal.'); finish(e); }
346
148
  });
347
-
149
+ server.on('error', err => finish(err));
348
150
  server.listen(0, '127.0.0.1', () => {
349
- const port = server.address().port;
350
- const redirectUri = `http://127.0.0.1:${port}/callback`;
351
- const authUrl = `${targetUrl}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&client_name=${encodeURIComponent(clientName)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256&scope=${encodeURIComponent(process.env.EPURE_SCOPES || 'read:agent write:triage')}&state=${state}`;
352
-
353
- console.log(`\nOpening browser to authorize with Epure (${targetUrl})...`);
354
- console.log(`If your browser does not open automatically, visit:`);
355
- console.log(` ${authUrl}\n`);
356
-
357
- openBrowser(authUrl);
151
+ const approval = new URL('/oauth/authorize', url);
152
+ approval.search = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', client_name: 'Epure CLI and MCP', redirect_uri: `http://127.0.0.1:${server.address().port}/callback`, code_challenge: challenge, code_challenge_method: 'S256', scope: options.scope || SCOPES.join(' '), state }).toString();
153
+ (options.log || (s => process.stderr.write(`${s}\n`)))(`Approve sign-in: ${approval.href}`);
154
+ if (!options.noBrowser) (options.openBrowser || openBrowser)(approval.href);
358
155
  });
359
-
360
- server.on('error', reject);
361
-
362
- // 2-minute timeout for authorization
363
- const timer = setTimeout(() => {
364
- server.close();
365
- reject(new Error('Browser authorization timed out (120s)'));
366
- }, 120000);
367
-
368
- server.on('close', () => {
369
- clearTimeout(timer);
370
- });
371
- });
372
- }
373
-
374
- async function loginDeviceCode(targetUrl, clientId, clientName) {
375
- const initRes = await postJson(`${targetUrl}/oauth/device/code`, {
376
- client_id: clientId,
377
- client_name: clientName,
378
- scope: process.env.EPURE_SCOPES || 'read:agent write:triage',
156
+ timer = setTimeout(() => finish(new ClientError('AUTH_TIMEOUT', 'Browser approval timed out. Run auth login again.', 4)), 180000);
157
+ options.signal?.addEventListener('abort', cancel, { once: true });
379
158
  });
380
-
381
- const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = initRes;
382
-
383
- const authUrl = verification_uri_complete || `${verification_uri}?user_code=${user_code}`;
384
- console.log(`\nTo authenticate this device, open your browser and visit:`);
385
- console.log(` ${authUrl}`);
386
- console.log(`Or visit ${verification_uri} and enter code:`);
387
- console.log(` ${user_code}\n`);
388
-
389
- openBrowser(authUrl);
390
-
391
- const pollIntervalMs = Math.max(interval, 2) * 1000;
392
- const deadline = Date.now() + expires_in * 1000;
393
-
394
- process.stdout.write('Waiting for approval in browser...');
395
-
396
- while (Date.now() < deadline) {
397
- await new Promise((r) => setTimeout(r, pollIntervalMs));
398
- process.stdout.write('.');
399
-
400
- try {
401
- const tokenRes = await postJson(`${targetUrl}/oauth/token`, {
402
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
403
- device_code,
404
- client_id: clientId,
405
- });
406
-
407
- if (tokenRes.access_token) {
408
- console.log(' Done!');
409
- const nowSec = Math.floor(Date.now() / 1000);
410
- saveInstance(targetUrl, {
411
- access_token: tokenRes.access_token,
412
- refresh_token: tokenRes.refresh_token,
413
- expires_at: nowSec + (tokenRes.expires_in || 2592000),
414
- org_id: tokenRes.org_id,
415
- user_id: tokenRes.user_id,
416
- scopes: (tokenRes.scope || 'read:agent write:triage').split(' '),
417
- });
418
-
419
- return {
420
- url: targetUrl,
421
- org_id: tokenRes.org_id,
422
- user_id: tokenRes.user_id,
423
- };
424
- }
425
- } catch (err) {
426
- if (err.message.includes('authorization_pending')) {
427
- continue;
428
- }
429
- console.log('');
430
- throw err;
431
- }
432
- }
433
-
434
- console.log('');
435
- throw new Error('Device activation expired. Please run login again.');
436
159
  }
437
-
438
160
  async function login(options = {}) {
439
- const targetUrl = await selectInstanceUrl(options.url);
440
- const clientId = options.clientId || 'epure-cli';
441
- const clientName = options.clientName || `${clientId} (${os.hostname()})`;
442
-
443
- const useDevice = options.device || Boolean(process.env.SSH_CLIENT) || !process.stdin.isTTY;
444
-
445
- if (useDevice) {
446
- return loginDeviceCode(targetUrl, clientId, clientName);
447
- }
448
-
449
- try {
450
- return await loginLoopback(targetUrl, clientId, clientName);
451
- } catch (err) {
452
- console.log(`Loopback failed (${err.message}). Falling back to Device Code Flow...`);
453
- return await loginDeviceCode(targetUrl, clientId, clientName);
454
- }
161
+ const url = endpoint(options.url);
162
+ const scopes = (options.scope || SCOPES.join(' ')).split(/[ ,]+/).filter(Boolean);
163
+ if (!scopes.length || scopes.some(s => !SCOPES.includes(s))) throw new ClientError('INVALID_SCOPE', `Choose scopes from: ${SCOPES.join(', ')}`, 2);
164
+ const opts = { ...options, scope: scopes.join(' ') };
165
+ return options.device || process.env.SSH_CLIENT || !process.stdin.isTTY ? loginDeviceCode(url, opts) : loginLoopback(url, opts);
166
+ }
167
+ async function logout(url) {
168
+ return withLock(async () => {
169
+ const target = endpoint(url); const c = getActiveCredentials(target); let revoked = false;
170
+ try { if (c?.refresh_token) { await post(`${target}/oauth/revoke`, { token: c.refresh_token, token_type_hint: 'refresh_token', client_id: c.client_id || CLIENT_ID }); revoked = true; } }
171
+ catch {} finally { removeInstance(target); }
172
+ return { instance: target, authenticated: false, revoked, ...(!revoked && c?.refresh_token ? { warning: 'Local session removed. Remote revocation failed; revoke Epure CLI and MCP in Settings → Connected apps.' } : {}) };
173
+ });
455
174
  }
456
-
457
- module.exports = {
458
- getCredentialsPath,
459
- readCredentials,
460
- writeCredentials,
461
- normalizeUrl,
462
- getActiveCredentials,
463
- saveInstance,
464
- switchInstance,
465
- removeInstance,
466
- listInstances,
467
- ensureValidToken,
468
- login,
469
- };
175
+ module.exports = { ClientError, authRequired, normalizeUrl, endpoint, getCredentialsPath, readCredentials, writeCredentials, saveInstance, getActiveCredentials, switchInstance, listInstances, removeInstance, ensureValidToken, publicSession, login, loginLoopback, loginDeviceCode, logout };