@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.
Files changed (2) hide show
  1. package/bin/gmtr-login.ts +161 -72
  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,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
- if (isHeadless()) {
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 local callback server
410
- const state = randomBytes(16).toString('base64url');
411
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
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
- 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}`);
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
- server.close();
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 Firebase token or state mismatch. Please try again.'));
438
- server.close();
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
- 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
510
+ res.writeHead(200, { 'Content-Type': 'text/html' });
511
+ res.end(successPage());
512
+ callbackServer.close();
513
+ resolve(code);
467
514
  });
468
-
469
- // Timeout after 2 minutes
470
- setTimeout(() => {
471
- server.close();
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
- const authorizeUrl = new URL('/login', `${DASHBOARD_BASE}/`);
477
- authorizeUrl.searchParams.set('callback', callbackUrl);
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
- 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');
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 dashboard login (browser or headless device flow)
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
- await loginBrowser();
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramatr/client",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },