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