@indigoai-us/hq-cloud 6.14.13 → 6.14.15

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.
@@ -565,3 +565,143 @@ describe("refresh expiry hardening", () => {
565
565
  expect(loadCachedTokens()?.accessToken).toBe("new-access");
566
566
  });
567
567
  });
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // Machine mint identity binding under EMAIL-ALIAS pools (regression, 2026-07-20)
571
+ //
572
+ // The prod pool aliases email-format usernames: Cognito's canonical username
573
+ // (`cognito:username` / access `username`) is a generated UUID equal to `sub`,
574
+ // NEVER the email-format username the machine creds carry. The 6.14.13
575
+ // predicate bound the ID token to creds via username equality only, so every
576
+ // valid outpost/agent mint was rejected with "Machine token mint returned
577
+ // tokens that do not match the requested machine identity" — a fleet-wide
578
+ // sync outage on boxes. Claim shapes below mirror a real prod outpost token
579
+ // (email claim ABSENT: the machine app client cannot read `email`, so Cognito
580
+ // omits it from the ID token entirely).
581
+ // ---------------------------------------------------------------------------
582
+
583
+ describe("machine mint under email-alias pools (username is a UUID)", () => {
584
+ const MACHINE_CLIENT = "4q3bmth9ad4v3be6booc5td193";
585
+ const OUTPOST_UID = "out_01KXVRESJMP7TD263P7S0YHPSD";
586
+ const COGNITO_INTERNAL_SUB = "f4981438-a0a1-704c-542a-dfac148f2380";
587
+ const CREDS_USERNAME = "out-01kxvresjmp7td263p7s0yhpsd@outposts.hq.computer";
588
+
589
+ function writeOutpostCreds(overrides: Record<string, unknown> = {}) {
590
+ const dir = path.join(tmpHome, ".hq-agent");
591
+ fs.mkdirSync(dir, { recursive: true });
592
+ fs.writeFileSync(
593
+ path.join(dir, "machine-creds.json"),
594
+ JSON.stringify({
595
+ username: CREDS_USERNAME,
596
+ secret: "machine-secret",
597
+ clientId: MACHINE_CLIENT,
598
+ region: "us-east-1",
599
+ entityType: "outpost",
600
+ entityUid: OUTPOST_UID,
601
+ ...overrides,
602
+ }),
603
+ );
604
+ }
605
+
606
+ function mintResponse(accessClaims: Record<string, unknown>, idClaims: Record<string, unknown>) {
607
+ return vi.fn(async () =>
608
+ new Response(
609
+ JSON.stringify({
610
+ AuthenticationResult: {
611
+ AccessToken: makeAccessToken(accessClaims),
612
+ IdToken: makeAccessToken(idClaims),
613
+ ExpiresIn: 3600,
614
+ },
615
+ }),
616
+ { status: 200, headers: { "Content-Type": "application/json" } },
617
+ ),
618
+ );
619
+ }
620
+
621
+ it("accepts a valid outpost mint whose cognito:username is the internal UUID (alias pool)", async () => {
622
+ const { getValidAccessToken } = await importModule();
623
+ writeOutpostCreds();
624
+ const fetchMock = mintResponse(
625
+ {
626
+ token_use: "access",
627
+ client_id: MACHINE_CLIENT,
628
+ // Alias pool: access username is the internal UUID, not the email-format name.
629
+ username: COGNITO_INTERNAL_SUB,
630
+ sub: COGNITO_INTERNAL_SUB,
631
+ },
632
+ {
633
+ token_use: "id",
634
+ aud: MACHINE_CLIENT,
635
+ "cognito:username": COGNITO_INTERNAL_SUB,
636
+ sub: COGNITO_INTERNAL_SUB,
637
+ // email claim intentionally ABSENT (client cannot read `email`).
638
+ "custom:entityType": "outpost",
639
+ "custom:entityUid": OUTPOST_UID,
640
+ "custom:delegatedSub": "24785418-c071-7077-b7e9-0b1b583cfba1",
641
+ "custom:delegatedEmail": "hassaan@getindigo.ai",
642
+ },
643
+ );
644
+ vi.stubGlobal("fetch", fetchMock);
645
+
646
+ await expect(
647
+ getValidAccessToken(baseConfig, { interactive: false }),
648
+ ).resolves.toBeTypeOf("string");
649
+ expect(fetchMock).toHaveBeenCalledTimes(1);
650
+ });
651
+
652
+ it("still rejects a mint whose entityUid does not match the requested creds", async () => {
653
+ const { getValidAccessToken } = await importModule();
654
+ writeOutpostCreds();
655
+ const fetchMock = mintResponse(
656
+ {
657
+ token_use: "access",
658
+ client_id: MACHINE_CLIENT,
659
+ username: COGNITO_INTERNAL_SUB,
660
+ sub: COGNITO_INTERNAL_SUB,
661
+ },
662
+ {
663
+ token_use: "id",
664
+ aud: MACHINE_CLIENT,
665
+ "cognito:username": COGNITO_INTERNAL_SUB,
666
+ sub: COGNITO_INTERNAL_SUB,
667
+ "custom:entityType": "outpost",
668
+ // A DIFFERENT machine's uid — identity binding must refuse it.
669
+ "custom:entityUid": "out_01KX0000000000000000000000",
670
+ "custom:delegatedSub": "24785418-c071-7077-b7e9-0b1b583cfba1",
671
+ "custom:delegatedEmail": "hassaan@getindigo.ai",
672
+ },
673
+ );
674
+ vi.stubGlobal("fetch", fetchMock);
675
+
676
+ await expect(
677
+ getValidAccessToken(baseConfig, { interactive: false }),
678
+ ).rejects.toThrow(/do not match the requested machine identity/);
679
+ });
680
+
681
+ it("still rejects legacy creds (no entityUid) when the username binding fails", async () => {
682
+ const { getValidAccessToken } = await importModule();
683
+ // Legacy agent creds: no entityType/entityUid persisted.
684
+ writeOutpostCreds({ entityType: undefined, entityUid: undefined, username: "agt-legacy@agents.getindigo.ai" });
685
+ const fetchMock = mintResponse(
686
+ {
687
+ token_use: "access",
688
+ client_id: MACHINE_CLIENT,
689
+ username: "someone-else",
690
+ sub: "someone-else",
691
+ },
692
+ {
693
+ token_use: "id",
694
+ aud: MACHINE_CLIENT,
695
+ "cognito:username": "someone-else",
696
+ sub: "someone-else",
697
+ "custom:entityType": "agent",
698
+ "custom:entityUid": "agt_01JZ9999999999999999999999",
699
+ },
700
+ );
701
+ vi.stubGlobal("fetch", fetchMock);
702
+
703
+ await expect(
704
+ getValidAccessToken(baseConfig, { interactive: false }),
705
+ ).rejects.toThrow(/do not match the requested machine identity/);
706
+ });
707
+ });
@@ -399,8 +399,23 @@ function machineTokenPairMatchesIdentity(
399
399
  const entityMatchesCreds =
400
400
  entityType === expectedEntityType &&
401
401
  (creds.entityUid === undefined || entityUid === creds.entityUid);
402
+ // Bind the ID token to the REQUESTED credentials. Username equality only
403
+ // holds in pools without email aliasing; the prod pool aliases email-format
404
+ // usernames, so Cognito's canonical username (`cognito:username`, access
405
+ // `username`, `sub`) is a generated UUID that NEVER equals the email-format
406
+ // creds.username — and the machine app client cannot read `email`, so no
407
+ // email claim rides the ID token either. For creds that persist their
408
+ // entityUid (every ADR-0009 outpost and modern agent), the unique
409
+ // custom:entityUid claim IS the identity binding: exactly one Cognito user
410
+ // carries that attribute value, stamped server-side at provisioning.
411
+ // Regression: 6.14.13 shipped username-only binding and rejected every
412
+ // valid machine mint in the alias pool ("Machine token mint returned tokens
413
+ // that do not match the requested machine identity"), logging out the
414
+ // outpost fleet. Legacy creds without entityUid keep the username binding.
402
415
  const idTokenMatchesCreds =
403
- idUsername === creds.username || idSub === creds.username;
416
+ idUsername === creds.username ||
417
+ idSub === creds.username ||
418
+ (creds.entityUid !== undefined && entityUid === creds.entityUid);
404
419
  const subjectBindings: boolean[] = [];
405
420
  if (accessUsername !== null && idUsername !== null) {
406
421
  subjectBindings.push(accessUsername === idUsername);