@gramatr/client 0.6.3 → 0.6.6
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/bin/gmtr-login.ts +180 -78
- package/package.json +1 -1
package/bin/gmtr-login.ts
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* The GMTRPromptEnricher hook reads this on every prompt.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { randomBytes } from 'crypto';
|
|
18
|
+
import { createHash, randomBytes } from 'crypto';
|
|
19
19
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
20
20
|
import { join } from 'path';
|
|
21
21
|
import { createServer, type IncomingMessage, type ServerResponse } from 'http';
|
|
22
|
+
import type { AddressInfo } from 'net';
|
|
22
23
|
|
|
23
24
|
// ── Config ──
|
|
24
25
|
|
|
@@ -41,7 +42,9 @@ const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL || (() => {
|
|
|
41
42
|
return 'https://app.gramatr.com';
|
|
42
43
|
}
|
|
43
44
|
})();
|
|
44
|
-
|
|
45
|
+
// CALLBACK_PORT is now dynamically allocated per login (random localhost port).
|
|
46
|
+
// The server's DCR endpoint accepts arbitrary localhost redirect_uris for
|
|
47
|
+
// public CLIs (token_endpoint_auth_method=none). See loginBrowser() below.
|
|
45
48
|
|
|
46
49
|
// ── HTML Templates ──
|
|
47
50
|
|
|
@@ -140,11 +143,16 @@ function errorPage(title: string, detail: string): string {
|
|
|
140
143
|
|
|
141
144
|
// ── Headless Detection ──
|
|
142
145
|
|
|
143
|
-
function isHeadless(): boolean {
|
|
144
|
-
//
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
function isHeadless(forceFlag: boolean = false): boolean {
|
|
147
|
+
// Explicit override — user passed --headless, OR env GRAMATR_LOGIN_HEADLESS=1
|
|
148
|
+
if (forceFlag) return true;
|
|
149
|
+
if (process.env.GRAMATR_LOGIN_HEADLESS === '1') return true;
|
|
150
|
+
|
|
151
|
+
// SSH session — always go headless. Even on macOS (which has a local
|
|
152
|
+
// display), `open` would launch Safari on the Mac's *physical* screen,
|
|
153
|
+
// not on the remote terminal where the user actually is. Device flow
|
|
154
|
+
// lets them paste the code on whichever machine they're sitting at.
|
|
155
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return true;
|
|
148
156
|
// Docker / CI / no TTY
|
|
149
157
|
if (process.env.CI || process.env.DOCKER) return true;
|
|
150
158
|
// Linux without display
|
|
@@ -339,7 +347,7 @@ async function loginWithToken(token: string): Promise<void> {
|
|
|
339
347
|
}
|
|
340
348
|
}
|
|
341
349
|
|
|
342
|
-
async function loginBrowser(): Promise<void> {
|
|
350
|
+
async function loginBrowser(opts: { forceHeadless?: boolean } = {}): Promise<void> {
|
|
343
351
|
console.log('\n gramatr login\n');
|
|
344
352
|
console.log(` Server: ${SERVER_BASE}`);
|
|
345
353
|
console.log(` Dashboard: ${DASHBOARD_BASE}`);
|
|
@@ -355,8 +363,11 @@ async function loginBrowser(): Promise<void> {
|
|
|
355
363
|
|
|
356
364
|
console.log('');
|
|
357
365
|
|
|
358
|
-
// Headless environments use device auth (no local server needed)
|
|
359
|
-
|
|
366
|
+
// Headless environments use device auth (no local server needed).
|
|
367
|
+
// --headless flag or GRAMATR_LOGIN_HEADLESS=1 forces this path even on
|
|
368
|
+
// desktop — escape hatch when the browser flow is broken or the user
|
|
369
|
+
// prefers the device flow's out-of-band UX.
|
|
370
|
+
if (isHeadless(opts.forceHeadless)) {
|
|
360
371
|
console.log(' Headless environment detected. Starting device login...\n');
|
|
361
372
|
try {
|
|
362
373
|
const device = await startDeviceAuthorization();
|
|
@@ -406,75 +417,127 @@ async function loginBrowser(): Promise<void> {
|
|
|
406
417
|
}
|
|
407
418
|
}
|
|
408
419
|
|
|
409
|
-
// Browser environments use
|
|
410
|
-
|
|
411
|
-
|
|
420
|
+
// ── Browser environments use OAuth 2.0 authorization-code grant + PKCE ──
|
|
421
|
+
//
|
|
422
|
+
// v0.6.5: This now mirrors the headless device flow's auth model — both
|
|
423
|
+
// paths produce an opaque `mcp-access-token-<uuid>` token (1-year TTL,
|
|
424
|
+
// Redis-backed) instead of the short-lived raw Firebase ID token the old
|
|
425
|
+
// dashboard-redirect flow stored. Per RFC 7591 (Dynamic Client Registration)
|
|
426
|
+
// we register the CLI as an OAuth client on first run, then per RFC 7636
|
|
427
|
+
// (PKCE) we exchange a code for the opaque token.
|
|
428
|
+
//
|
|
429
|
+
// Bug history: pre-v0.6.5 the browser flow stored a raw Firebase ID token
|
|
430
|
+
// (token_type: 'firebase'), which had a 1-hour TTL. After 1 hour every
|
|
431
|
+
// MCP call returned "JWT signature validation failed" because the token
|
|
432
|
+
// genuinely expired. See #519 + #524 + this PR.
|
|
433
|
+
|
|
434
|
+
// 1. Bind a localhost callback server (random port — server's DCR allows it)
|
|
435
|
+
const callbackServer = createServer();
|
|
436
|
+
await new Promise<void>((resolve, reject) => {
|
|
437
|
+
callbackServer.once('error', reject);
|
|
438
|
+
callbackServer.listen(0, '127.0.0.1', () => resolve());
|
|
439
|
+
});
|
|
440
|
+
const port = (callbackServer.address() as AddressInfo).port;
|
|
441
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
412
442
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
443
|
+
// 2. Dynamic client registration (RFC 7591). We always re-register because
|
|
444
|
+
// the redirect_uri changes every run (random localhost port). Server
|
|
445
|
+
// allows DCR with token_endpoint_auth_method=none for public CLIs.
|
|
446
|
+
const config = readConfig();
|
|
447
|
+
let clientId: string;
|
|
448
|
+
try {
|
|
449
|
+
const regRes = await fetch(`${SERVER_BASE}/register`, {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
headers: { 'Content-Type': 'application/json' },
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
client_name: 'gmtr-login',
|
|
454
|
+
redirect_uris: [redirectUri],
|
|
455
|
+
grant_types: ['authorization_code'],
|
|
456
|
+
response_types: ['code'],
|
|
457
|
+
token_endpoint_auth_method: 'none',
|
|
458
|
+
}),
|
|
459
|
+
signal: AbortSignal.timeout(10000),
|
|
460
|
+
});
|
|
461
|
+
const reg = await regRes.json().catch(() => ({}));
|
|
462
|
+
if (!regRes.ok || !reg.client_id) {
|
|
463
|
+
throw new Error(reg.error_description || reg.error || `HTTP ${regRes.status}`);
|
|
464
|
+
}
|
|
465
|
+
clientId = reg.client_id as string;
|
|
466
|
+
config.oauth_client_id = clientId;
|
|
467
|
+
writeConfig(config);
|
|
468
|
+
} catch (e: any) {
|
|
469
|
+
callbackServer.close();
|
|
470
|
+
console.log(` ✗ Dynamic client registration failed: ${e.message}\n`);
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
416
473
|
|
|
474
|
+
// 3. PKCE: generate verifier + S256 challenge
|
|
475
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
476
|
+
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
|
477
|
+
const state = randomBytes(16).toString('base64url');
|
|
478
|
+
|
|
479
|
+
// 4. Run the callback server, waiting for /callback?code=...&state=...
|
|
480
|
+
//
|
|
481
|
+
// v0.6.5: capture the timeout handle and clearTimeout() in finally — same
|
|
482
|
+
// orphan-timer bug that v0.3.63 fixed for the device flow. Without the
|
|
483
|
+
// clear, Promise.race against a setTimeout keeps the Node event loop
|
|
484
|
+
// alive past success and the process hangs until Ctrl+C.
|
|
485
|
+
let codeTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
486
|
+
const codePromise = new Promise<string>((resolve, reject) => {
|
|
487
|
+
callbackServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
|
|
488
|
+
const url = new URL(req.url || '/', redirectUri);
|
|
417
489
|
if (url.pathname !== '/callback') {
|
|
418
490
|
res.writeHead(404);
|
|
419
491
|
res.end('Not found');
|
|
420
492
|
return;
|
|
421
493
|
}
|
|
422
|
-
|
|
423
|
-
const token = url.searchParams.get('token');
|
|
494
|
+
const code = url.searchParams.get('code');
|
|
424
495
|
const returnedState = url.searchParams.get('state');
|
|
425
496
|
const error = url.searchParams.get('error');
|
|
426
|
-
|
|
497
|
+
// v0.6.6: `server.close()` alone does not terminate keep-alive
|
|
498
|
+
// sockets — the browser holds the connection open and the Node
|
|
499
|
+
// event loop never exits, so the CLI prints "Authenticated" and
|
|
500
|
+
// then hangs until Ctrl+C. Send `Connection: close` on the
|
|
501
|
+
// response and call `closeAllConnections()` to forcibly drop
|
|
502
|
+
// lingering sockets after we respond.
|
|
503
|
+
const shutdown = () => {
|
|
504
|
+
callbackServer.close();
|
|
505
|
+
if (typeof (callbackServer as any).closeAllConnections === 'function') {
|
|
506
|
+
(callbackServer as any).closeAllConnections();
|
|
507
|
+
}
|
|
508
|
+
};
|
|
427
509
|
if (error) {
|
|
428
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
510
|
+
res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
|
|
429
511
|
res.end(errorPage('Authentication Failed', error));
|
|
430
|
-
|
|
512
|
+
shutdown();
|
|
431
513
|
reject(new Error(`OAuth error: ${error}`));
|
|
432
514
|
return;
|
|
433
515
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
res.
|
|
437
|
-
|
|
438
|
-
server.close();
|
|
516
|
+
if (!code || returnedState !== state) {
|
|
517
|
+
res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
|
|
518
|
+
res.end(errorPage('Invalid Callback', 'Missing code or state mismatch. Please try again.'));
|
|
519
|
+
shutdown();
|
|
439
520
|
reject(new Error('Invalid callback'));
|
|
440
521
|
return;
|
|
441
522
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
447
|
-
res.end(errorPage('Token Validation Failed', validation.error || 'Server rejected token'));
|
|
448
|
-
server.close();
|
|
449
|
-
reject(new Error(validation.error || 'Server rejected token'));
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
454
|
-
res.end(successPage());
|
|
455
|
-
server.close();
|
|
456
|
-
resolve(token);
|
|
457
|
-
} catch (e: any) {
|
|
458
|
-
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
459
|
-
res.end(errorPage('Unexpected Error', e.message));
|
|
460
|
-
server.close();
|
|
461
|
-
reject(e);
|
|
462
|
-
}
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
server.listen(CALLBACK_PORT, () => {
|
|
466
|
-
// Server ready
|
|
523
|
+
res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
|
|
524
|
+
res.end(successPage());
|
|
525
|
+
shutdown();
|
|
526
|
+
resolve(code);
|
|
467
527
|
});
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
reject(new Error('Login timed out after 2 minutes'));
|
|
473
|
-
}, 120000);
|
|
528
|
+
codeTimeoutHandle = setTimeout(() => {
|
|
529
|
+
callbackServer.close();
|
|
530
|
+
reject(new Error('Login timed out after 5 minutes'));
|
|
531
|
+
}, 5 * 60 * 1000);
|
|
474
532
|
});
|
|
475
533
|
|
|
476
|
-
|
|
477
|
-
authorizeUrl
|
|
534
|
+
// 5. Open the browser to the server's /authorize endpoint with PKCE params
|
|
535
|
+
const authorizeUrl = new URL('/authorize', SERVER_BASE);
|
|
536
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
537
|
+
authorizeUrl.searchParams.set('client_id', clientId);
|
|
538
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
539
|
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
|
|
540
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
478
541
|
authorizeUrl.searchParams.set('state', state);
|
|
479
542
|
|
|
480
543
|
console.log(' Opening browser for authentication...');
|
|
@@ -482,33 +545,64 @@ async function loginBrowser(): Promise<void> {
|
|
|
482
545
|
console.log(` ${authorizeUrl.toString()}`);
|
|
483
546
|
console.log('');
|
|
484
547
|
|
|
485
|
-
// Open browser
|
|
486
548
|
const { exec } = await import('child_process');
|
|
487
549
|
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
488
550
|
exec(`${openCmd} "${authorizeUrl.toString()}"`);
|
|
489
551
|
|
|
490
552
|
console.log(' Waiting for authorization...');
|
|
491
553
|
|
|
554
|
+
let authCode: string;
|
|
492
555
|
try {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
// Save token
|
|
496
|
-
const config = readConfig();
|
|
497
|
-
config.token = accessToken;
|
|
498
|
-
config.token_type = 'firebase';
|
|
499
|
-
config.authenticated_at = new Date().toISOString();
|
|
500
|
-
config.server_url = SERVER_BASE;
|
|
501
|
-
config.dashboard_url = DASHBOARD_BASE;
|
|
502
|
-
writeConfig(config);
|
|
503
|
-
|
|
504
|
-
console.log('');
|
|
505
|
-
console.log(' ✓ Authenticated successfully');
|
|
506
|
-
console.log(' Token saved to ~/.gramatr.json');
|
|
507
|
-
console.log(' gramatr intelligence is now active.\n');
|
|
556
|
+
authCode = await codePromise;
|
|
508
557
|
} catch (e: any) {
|
|
558
|
+
if (codeTimeoutHandle) clearTimeout(codeTimeoutHandle);
|
|
509
559
|
console.log(`\n ✗ Authentication failed: ${e.message}\n`);
|
|
510
560
|
process.exit(1);
|
|
511
561
|
}
|
|
562
|
+
if (codeTimeoutHandle) clearTimeout(codeTimeoutHandle);
|
|
563
|
+
|
|
564
|
+
// 6. Exchange the code for an opaque MCP access token at /token
|
|
565
|
+
let accessToken: string;
|
|
566
|
+
let expiresIn: number = 31536000; // server default = 1 year
|
|
567
|
+
try {
|
|
568
|
+
const tokenRes = await fetch(`${SERVER_BASE}/token`, {
|
|
569
|
+
method: 'POST',
|
|
570
|
+
headers: { 'Content-Type': 'application/json' },
|
|
571
|
+
body: JSON.stringify({
|
|
572
|
+
grant_type: 'authorization_code',
|
|
573
|
+
code: authCode,
|
|
574
|
+
code_verifier: codeVerifier,
|
|
575
|
+
client_id: clientId,
|
|
576
|
+
redirect_uri: redirectUri,
|
|
577
|
+
}),
|
|
578
|
+
signal: AbortSignal.timeout(10000),
|
|
579
|
+
});
|
|
580
|
+
const payload = await tokenRes.json().catch(() => ({}));
|
|
581
|
+
if (!tokenRes.ok || !payload.access_token) {
|
|
582
|
+
throw new Error(payload.error_description || payload.error || `HTTP ${tokenRes.status}`);
|
|
583
|
+
}
|
|
584
|
+
accessToken = payload.access_token as string;
|
|
585
|
+
if (typeof payload.expires_in === 'number') expiresIn = payload.expires_in;
|
|
586
|
+
} catch (e: any) {
|
|
587
|
+
console.log(`\n ✗ Token exchange failed: ${e.message}\n`);
|
|
588
|
+
process.exit(1);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 7. Save the opaque token — same shape as the device flow
|
|
592
|
+
const updated = readConfig();
|
|
593
|
+
updated.token = accessToken;
|
|
594
|
+
updated.token_type = 'oauth';
|
|
595
|
+
updated.authenticated_at = new Date().toISOString();
|
|
596
|
+
updated.server_url = SERVER_BASE;
|
|
597
|
+
updated.dashboard_url = DASHBOARD_BASE;
|
|
598
|
+
updated.token_expires_at = new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
599
|
+
if (clientId) updated.oauth_client_id = clientId;
|
|
600
|
+
writeConfig(updated);
|
|
601
|
+
|
|
602
|
+
console.log('');
|
|
603
|
+
console.log(' ✓ Authenticated successfully');
|
|
604
|
+
console.log(' Token saved to ~/.gramatr.json');
|
|
605
|
+
console.log(' gramatr intelligence is now active.\n');
|
|
512
606
|
}
|
|
513
607
|
|
|
514
608
|
// ── CLI ──
|
|
@@ -563,13 +657,19 @@ export async function main(): Promise<void> {
|
|
|
563
657
|
gmtr-login — Authenticate with the gramatr server
|
|
564
658
|
|
|
565
659
|
Usage:
|
|
566
|
-
gmtr-login Interactive
|
|
660
|
+
gmtr-login Interactive login (browser PKCE when display available,
|
|
661
|
+
device flow otherwise — auto-detected)
|
|
662
|
+
gmtr-login --headless Force the device flow even on desktops (escape hatch
|
|
663
|
+
when the browser flow is broken or unavailable)
|
|
567
664
|
gmtr-login --token Paste a token (API key or Firebase token)
|
|
568
665
|
gmtr-login --token <t> Provide token directly
|
|
569
666
|
gmtr-login --status Check authentication status
|
|
570
667
|
gmtr-login --logout Remove stored credentials
|
|
571
668
|
gmtr-login --help Show this help
|
|
572
669
|
|
|
670
|
+
Environment:
|
|
671
|
+
GRAMATR_LOGIN_HEADLESS=1 Same as --headless (forces device flow)
|
|
672
|
+
|
|
573
673
|
Token storage: ~/.gramatr.json
|
|
574
674
|
Server: ${SERVER_BASE}
|
|
575
675
|
Dashboard: ${DASHBOARD_BASE}
|
|
@@ -577,8 +677,10 @@ export async function main(): Promise<void> {
|
|
|
577
677
|
return;
|
|
578
678
|
}
|
|
579
679
|
|
|
580
|
-
// Default: browser login flow
|
|
581
|
-
|
|
680
|
+
// Default: browser login flow (falls back to device flow if headless
|
|
681
|
+
// detected, OR if --headless / GRAMATR_LOGIN_HEADLESS=1 is set).
|
|
682
|
+
const forceHeadless = args.includes('--headless');
|
|
683
|
+
await loginBrowser({ forceHeadless });
|
|
582
684
|
}
|
|
583
685
|
|
|
584
686
|
// Module-run guard. Works both when invoked directly via
|