@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.
Files changed (2) hide show
  1. package/bin/gmtr-login.ts +180 -78
  2. 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
- const CALLBACK_PORT = 58787; // Must match server's redirect_uris
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
- // SSH session without display forwarding
145
- if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
146
- if (!process.env.DISPLAY && process.platform !== 'darwin') return true;
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
- if (isHeadless()) {
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 local callback server
410
- const state = randomBytes(16).toString('base64url');
411
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
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
- const tokenPromise = new Promise<string>((resolve, reject) => {
414
- const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
415
- const url = new URL(req.url || '/', `http://localhost:${CALLBACK_PORT}`);
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
- server.close();
512
+ shutdown();
431
513
  reject(new Error(`OAuth error: ${error}`));
432
514
  return;
433
515
  }
434
-
435
- if (!token || returnedState !== state) {
436
- res.writeHead(400, { 'Content-Type': 'text/html' });
437
- res.end(errorPage('Invalid Callback', 'Missing Firebase token or state mismatch. Please try again.'));
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
- try {
444
- const validation = await testToken(token);
445
- if (!validation.valid) {
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
- // Timeout after 2 minutes
470
- setTimeout(() => {
471
- server.close();
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
- const authorizeUrl = new URL('/login', `${DASHBOARD_BASE}/`);
477
- authorizeUrl.searchParams.set('callback', callbackUrl);
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
- const accessToken = await tokenPromise;
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 dashboard login (browser or headless device flow)
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
- await loginBrowser();
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramatr/client",
3
- "version": "0.6.3",
3
+ "version": "0.6.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },