@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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/dist/bin/sync-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/cognito-auth.ts
DELETED
|
@@ -1,1422 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cognito browser-OAuth helper (VLT-9).
|
|
3
|
-
*
|
|
4
|
-
* Drives the Cognito Hosted UI authorization-code + PKCE flow for the
|
|
5
|
-
* vault-service User Pool. Used by the CLI (`hq login`, `create-hq`) to
|
|
6
|
-
* obtain a JWT that is then passed to the vault-service API as
|
|
7
|
-
* `Authorization: Bearer <accessToken>`.
|
|
8
|
-
*
|
|
9
|
-
* Why PKCE: the CLI is a public client (no secret), so we use PKCE per
|
|
10
|
-
* RFC 7636 to prove that the same process that started the auth request
|
|
11
|
-
* is the one exchanging the code for tokens.
|
|
12
|
-
*
|
|
13
|
-
* Why a localhost callback: Cognito allows `http://localhost:*` as a
|
|
14
|
-
* redirect URI specifically for native/CLI apps (RFC 8252 §7). We spin
|
|
15
|
-
* up a one-shot HTTP server on the chosen port, capture exactly one
|
|
16
|
-
* callback, then close it.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import * as crypto from "crypto";
|
|
20
|
-
import * as fs from "fs";
|
|
21
|
-
import * as http from "http";
|
|
22
|
-
import * as path from "path";
|
|
23
|
-
import * as os from "os";
|
|
24
|
-
import open from "open";
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Types
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
export interface CognitoAuthConfig {
|
|
31
|
-
/** AWS region the User Pool lives in (e.g. "us-east-1"). */
|
|
32
|
-
region: string;
|
|
33
|
-
/** Cognito User Pool Domain prefix (e.g. "vault-indigo-hq-dev"). */
|
|
34
|
-
userPoolDomain: string;
|
|
35
|
-
/** App Client ID (e.g. "7r7an9keh0u6hlsvepl74tvqb0"). */
|
|
36
|
-
clientId: string;
|
|
37
|
-
/** Loopback callback port. Defaults to 3000. */
|
|
38
|
-
port?: number;
|
|
39
|
-
/** OAuth scopes. Defaults to ["openid", "email", "profile"]. */
|
|
40
|
-
scopes?: string[];
|
|
41
|
-
/**
|
|
42
|
-
* Force a federated IdP (e.g. "Google"). When set, the Hosted UI IdP picker
|
|
43
|
-
* is bypassed and Cognito redirects straight to the provider. When omitted,
|
|
44
|
-
* Cognito shows its default picker.
|
|
45
|
-
*/
|
|
46
|
-
identityProvider?: string;
|
|
47
|
-
/**
|
|
48
|
-
* OAuth `prompt` param (e.g. "select_account"). Only meaningful when the IdP
|
|
49
|
-
* honors it — Google uses it to force account re-selection.
|
|
50
|
-
*/
|
|
51
|
-
prompt?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface CognitoTokens {
|
|
55
|
-
accessToken: string;
|
|
56
|
-
idToken: string;
|
|
57
|
-
refreshToken: string;
|
|
58
|
-
/** Epoch milliseconds when the access token expires. Writers MUST emit a number. Readers accept ISO 8601 strings for backward compatibility with pre-migration token files. */
|
|
59
|
-
expiresAt: string | number;
|
|
60
|
-
tokenType: "Bearer";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Returned when an interactive login is needed but stdin/browser is unavailable. */
|
|
64
|
-
export class CognitoAuthError extends Error {
|
|
65
|
-
constructor(message: string) {
|
|
66
|
-
super(message);
|
|
67
|
-
this.name = "CognitoAuthError";
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* A failed refresh attempt with enough classification for callers to decide
|
|
73
|
-
* whether the cached session is still worth preserving. Network/5xx/429
|
|
74
|
-
* failures remain recoverable; an invalid/expired refresh token means the
|
|
75
|
-
* stored session can no longer make progress and should be cleared.
|
|
76
|
-
*/
|
|
77
|
-
export class CognitoRefreshError extends CognitoAuthError {
|
|
78
|
-
public readonly requiresReauth: boolean;
|
|
79
|
-
public readonly statusCode?: number;
|
|
80
|
-
public readonly sessionFingerprint?: string;
|
|
81
|
-
|
|
82
|
-
constructor(
|
|
83
|
-
message: string,
|
|
84
|
-
requiresReauth: boolean,
|
|
85
|
-
statusCode?: number,
|
|
86
|
-
sessionFingerprint?: string,
|
|
87
|
-
) {
|
|
88
|
-
super(message);
|
|
89
|
-
this.name = "CognitoRefreshError";
|
|
90
|
-
this.requiresReauth = requiresReauth;
|
|
91
|
-
this.statusCode = statusCode;
|
|
92
|
-
this.sessionFingerprint = sessionFingerprint;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
// Token cache (~/.hq/cognito-tokens.json)
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Root for hq CLI state: the token cache and its refresh lock.
|
|
102
|
-
*
|
|
103
|
-
* `HQ_STATE_DIR` overrides it, mirroring the existing `HQ_MACHINE_CREDS_FILE`
|
|
104
|
-
* override for the creds path. Before this existed the location was
|
|
105
|
-
* `os.homedir()/.hq` with NO escape hatch, which made every auth path — machine
|
|
106
|
-
* identity included — require a writable HOME. A child job running with a
|
|
107
|
-
* read-only HQ state directory therefore died on a *cache write* before it ever
|
|
108
|
-
* reached the network:
|
|
109
|
-
*
|
|
110
|
-
* EACCES: permission denied, open '…/.hq/cognito-tokens.json.lock.candidate.…'
|
|
111
|
-
*
|
|
112
|
-
* Pointing HQ_STATE_DIR at a writable tmpfs lets such a job authenticate while
|
|
113
|
-
* leaving the credentials themselves wherever they are.
|
|
114
|
-
*
|
|
115
|
-
* Resolved per call (not once at import) so a caller can set it during startup
|
|
116
|
-
* and so tests can exercise both branches.
|
|
117
|
-
*/
|
|
118
|
-
export function hqStateDir(): string {
|
|
119
|
-
const override = process.env.HQ_STATE_DIR?.trim();
|
|
120
|
-
return override !== undefined && override.length > 0
|
|
121
|
-
? override
|
|
122
|
-
: path.join(os.homedir(), ".hq");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** The Cognito token cache file inside {@link hqStateDir}. */
|
|
126
|
-
export function tokenCacheFile(): string {
|
|
127
|
-
return path.join(hqStateDir(), "cognito-tokens.json");
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function tokenInvalidationPrefix(): string {
|
|
131
|
-
return `${tokenCacheFile()}.invalid.`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Stable, non-secret identity for one access-token generation. */
|
|
135
|
-
export function accessTokenFingerprint(accessToken: string): string {
|
|
136
|
-
return crypto.createHash("sha256").update(accessToken).digest("hex");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function invalidationPathForFingerprint(fingerprint: string): string | null {
|
|
140
|
-
return /^[a-f0-9]{64}$/i.test(fingerprint)
|
|
141
|
-
? `${tokenInvalidationPrefix()}${fingerprint.toLowerCase()}`
|
|
142
|
-
: null;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Mark only the rejected token generation unusable. Automatic auth failures
|
|
147
|
-
* must not unlink the shared cache: another process may have completed a
|
|
148
|
-
* refresh or login after the failing request started.
|
|
149
|
-
*/
|
|
150
|
-
export function invalidateCachedTokensByFingerprint(
|
|
151
|
-
fingerprint: string | undefined,
|
|
152
|
-
): void {
|
|
153
|
-
if (!fingerprint) return;
|
|
154
|
-
const invalidationPath = invalidationPathForFingerprint(fingerprint);
|
|
155
|
-
if (!invalidationPath) return;
|
|
156
|
-
if (!fs.existsSync(hqStateDir())) {
|
|
157
|
-
fs.mkdirSync(hqStateDir(), { recursive: true, mode: 0o700 });
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
fs.writeFileSync(invalidationPath, "", { mode: 0o600, flag: "wx" });
|
|
161
|
-
} catch (err) {
|
|
162
|
-
const code =
|
|
163
|
-
err && typeof err === "object" && "code" in err
|
|
164
|
-
? (err as { code?: string }).code
|
|
165
|
-
: undefined;
|
|
166
|
-
if (code !== "EEXIST") throw err;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function tokenIsInvalidated(accessToken: string): boolean {
|
|
171
|
-
const marker = invalidationPathForFingerprint(
|
|
172
|
-
accessTokenFingerprint(accessToken),
|
|
173
|
-
);
|
|
174
|
-
return marker !== null && fs.existsSync(marker);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* True for the errno family a read-only / not-writable state directory raises.
|
|
179
|
-
*
|
|
180
|
-
* A job whose HQ state dir is mounted read-only (container, restricted
|
|
181
|
-
* sandbox, root-owned dir entered as another user) must still be able to
|
|
182
|
-
* AUTHENTICATE — persisting the session is an optimisation, not a requirement.
|
|
183
|
-
*/
|
|
184
|
-
function isNotWritableError(err: unknown): boolean {
|
|
185
|
-
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
186
|
-
return code === "EROFS" || code === "EACCES" || code === "EPERM";
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Process-local fallback used when the state directory cannot be written.
|
|
191
|
-
*
|
|
192
|
-
* Keeps the cache semantics intact for the life of the process instead of
|
|
193
|
-
* failing the call outright. It is deliberately NOT a substitute for the shared
|
|
194
|
-
* on-disk cache: a fresh subprocess starts empty and re-mints.
|
|
195
|
-
*/
|
|
196
|
-
let inMemoryCachedTokens: CognitoTokens | null = null;
|
|
197
|
-
|
|
198
|
-
export function loadCachedTokens(): CognitoTokens | null {
|
|
199
|
-
if (!fs.existsSync(tokenCacheFile())) return inMemoryCachedTokens;
|
|
200
|
-
try {
|
|
201
|
-
const raw = fs.readFileSync(tokenCacheFile(), "utf-8");
|
|
202
|
-
const tokens = JSON.parse(raw) as CognitoTokens;
|
|
203
|
-
if (!tokens.accessToken || tokenIsInvalidated(tokens.accessToken)) return null;
|
|
204
|
-
return tokens;
|
|
205
|
-
} catch {
|
|
206
|
-
return inMemoryCachedTokens;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export function saveCachedTokens(tokens: CognitoTokens): void {
|
|
211
|
-
// Remember in-process FIRST: whatever happens to the disk write below, this
|
|
212
|
-
// process must not lose a session it already successfully minted.
|
|
213
|
-
inMemoryCachedTokens = tokens;
|
|
214
|
-
try {
|
|
215
|
-
if (!fs.existsSync(hqStateDir())) {
|
|
216
|
-
fs.mkdirSync(hqStateDir(), { recursive: true, mode: 0o700 });
|
|
217
|
-
}
|
|
218
|
-
const invalidationPath = invalidationPathForFingerprint(
|
|
219
|
-
accessTokenFingerprint(tokens.accessToken),
|
|
220
|
-
);
|
|
221
|
-
if (invalidationPath && fs.existsSync(invalidationPath)) {
|
|
222
|
-
fs.unlinkSync(invalidationPath);
|
|
223
|
-
}
|
|
224
|
-
const tmpPath = path.join(
|
|
225
|
-
hqStateDir(),
|
|
226
|
-
`.cognito-tokens.json.tmp.${process.pid}`,
|
|
227
|
-
);
|
|
228
|
-
fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
229
|
-
fs.renameSync(tmpPath, tokenCacheFile());
|
|
230
|
-
} catch (err) {
|
|
231
|
-
// A read-only state dir degrades to the in-memory cache. Any other error
|
|
232
|
-
// (disk full, corrupt path) is still a real fault and propagates.
|
|
233
|
-
if (!isNotWritableError(err)) throw err;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function clearCachedTokens(): void {
|
|
238
|
-
if (fs.existsSync(tokenCacheFile())) fs.unlinkSync(tokenCacheFile());
|
|
239
|
-
if (!fs.existsSync(hqStateDir())) return;
|
|
240
|
-
const markerPrefix = `${path.basename(tokenCacheFile())}.invalid.`;
|
|
241
|
-
for (const entry of fs.readdirSync(hqStateDir())) {
|
|
242
|
-
if (entry.startsWith(markerPrefix)) {
|
|
243
|
-
fs.unlinkSync(path.join(hqStateDir(), entry));
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Advisory lockfile guarding the read→refresh→save cycle (see withRefreshLock). */
|
|
249
|
-
function refreshLockFile(): string {
|
|
250
|
-
return `${tokenCacheFile()}.lock`;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Thrown when the refresh lock can't be acquired within the deadline. */
|
|
254
|
-
export class RefreshLockTimeoutError extends Error {
|
|
255
|
-
constructor() {
|
|
256
|
-
super("timed out acquiring the token refresh lock");
|
|
257
|
-
this.name = "RefreshLockTimeoutError";
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** The pid recorded in the lockfile, or null if absent/empty/malformed. */
|
|
262
|
-
function readLockOwnerPid(): number | null {
|
|
263
|
-
try {
|
|
264
|
-
const pid = Number.parseInt(fs.readFileSync(refreshLockFile(), "utf-8").trim(), 10);
|
|
265
|
-
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
266
|
-
} catch {
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Whether `pid` is a live process. All contenders for this lock share one host
|
|
273
|
-
* (the cache is `~/.hq`), so pid liveness is authoritative. `EPERM` means the
|
|
274
|
-
* process exists but is owned by another user — still alive; any other error
|
|
275
|
-
* (`ESRCH`, bad pid) means it's gone.
|
|
276
|
-
*/
|
|
277
|
-
function processAlive(pid: number): boolean {
|
|
278
|
-
try {
|
|
279
|
-
process.kill(pid, 0);
|
|
280
|
-
return true;
|
|
281
|
-
} catch (err) {
|
|
282
|
-
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Serialize the token refresh/mint cycle across every process sharing this
|
|
288
|
-
* cache (~/.hq/cognito-tokens.json). Boxes run several hq processes (the
|
|
289
|
-
* auth-refresh timer, the sync runner, replica-sync) that refresh or mint the
|
|
290
|
-
* same session concurrently; without coordination they race on the shared
|
|
291
|
-
* cache. And if the app client ever issues single-use tokens (refresh-token
|
|
292
|
-
* rotation), two unsynchronized refreshers each send the same token — the first
|
|
293
|
-
* consumes it, the second gets `invalid_grant`. An advisory lockfile (atomic
|
|
294
|
-
* hard-link publication) makes them take turns; the caller MUST re-read the cache
|
|
295
|
-
* inside `fn` so a waiter reuses the winner's fresh token, never a consumed one.
|
|
296
|
-
* This is also the lock the per-box machine-identity mint path reuses (ADR-0009).
|
|
297
|
-
*
|
|
298
|
-
* Correctness over availability at the edges:
|
|
299
|
-
* - A held lock is reclaimed ONLY when its recorded holder pid is dead (a real
|
|
300
|
-
* crash) or the lockfile is malformed — NEVER because a live holder is merely
|
|
301
|
-
* slow (a slow Cognito response must not let a second refresher race in).
|
|
302
|
-
* - Release unlinks ONLY our own lock (pid re-checked), so we can't delete a
|
|
303
|
-
* successor's lock.
|
|
304
|
-
* - If we can't acquire within `timeoutMs` (a live holder never released — a
|
|
305
|
-
* hung network), we THROW rather than refresh unlocked; the caller prefers a
|
|
306
|
-
* still-valid cached token and otherwise retries next cycle. Proceeding
|
|
307
|
-
* unlocked would reintroduce the exact rotation race this guards.
|
|
308
|
-
*/
|
|
309
|
-
async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
310
|
-
const timeoutMs = 20_000;
|
|
311
|
-
const pollMs = 50;
|
|
312
|
-
try {
|
|
313
|
-
if (!fs.existsSync(hqStateDir())) {
|
|
314
|
-
fs.mkdirSync(hqStateDir(), { recursive: true, mode: 0o700 });
|
|
315
|
-
}
|
|
316
|
-
} catch (err) {
|
|
317
|
-
if (!isNotWritableError(err)) throw err;
|
|
318
|
-
return await fn();
|
|
319
|
-
}
|
|
320
|
-
// Publish a fully-written inode with an atomic hard link. Creating the lock
|
|
321
|
-
// itself and then writing the pid leaves an observable empty-file window in
|
|
322
|
-
// which a contender can misclassify and unlink a live holder's lock.
|
|
323
|
-
const candidate = `${refreshLockFile()}.candidate.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
|
|
324
|
-
try {
|
|
325
|
-
fs.writeFileSync(candidate, String(process.pid), {
|
|
326
|
-
flag: "wx",
|
|
327
|
-
mode: 0o600,
|
|
328
|
-
});
|
|
329
|
-
} catch (err) {
|
|
330
|
-
if (!isNotWritableError(err)) throw err;
|
|
331
|
-
// The state dir is not writable, so NO process can be persisting to this
|
|
332
|
-
// cache — there is nothing to serialize against and the lock's only job is
|
|
333
|
-
// moot. Run unlocked rather than failing the caller's authentication on a
|
|
334
|
-
// lockfile we neither need nor can create. (Before this, a read-only state
|
|
335
|
-
// dir raised EACCES here, killing machine auth before any network call.)
|
|
336
|
-
return await fn();
|
|
337
|
-
}
|
|
338
|
-
const deadline = Date.now() + timeoutMs;
|
|
339
|
-
let held = false;
|
|
340
|
-
try {
|
|
341
|
-
for (;;) {
|
|
342
|
-
try {
|
|
343
|
-
fs.linkSync(candidate, refreshLockFile());
|
|
344
|
-
held = true;
|
|
345
|
-
break;
|
|
346
|
-
} catch (err) {
|
|
347
|
-
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
348
|
-
const owner = readLockOwnerPid();
|
|
349
|
-
if (owner === null || (owner !== process.pid && !processAlive(owner))) {
|
|
350
|
-
try {
|
|
351
|
-
fs.unlinkSync(refreshLockFile()); // crashed/malformed holder — reclaim
|
|
352
|
-
} catch {
|
|
353
|
-
/* raced with another reclaimer */
|
|
354
|
-
}
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
if (Date.now() >= deadline) throw new RefreshLockTimeoutError();
|
|
358
|
-
await sleep(pollMs);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
return await fn();
|
|
362
|
-
} finally {
|
|
363
|
-
// Release ONLY our own lock — never clobber a successor's.
|
|
364
|
-
if (held && readLockOwnerPid() === process.pid) {
|
|
365
|
-
try {
|
|
366
|
-
fs.unlinkSync(refreshLockFile());
|
|
367
|
-
} catch {
|
|
368
|
-
/* already released */
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
// Remove the private proposal inode name on success or acquisition failure.
|
|
372
|
-
try {
|
|
373
|
-
fs.unlinkSync(candidate);
|
|
374
|
-
} catch {
|
|
375
|
-
/* already removed */
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Parse `expiresAt` to epoch-ms. Canonical on-disk shape is epoch milliseconds
|
|
382
|
-
* (number). Older token files may contain ISO 8601 strings. Accept both for
|
|
383
|
-
* migration safety. Returns null for anything unparseable — callers should
|
|
384
|
-
* treat that as "expired" and force a refresh.
|
|
385
|
-
*/
|
|
386
|
-
function parseExpiresAtMs(raw: unknown): number | null {
|
|
387
|
-
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
|
388
|
-
if (typeof raw === "string") {
|
|
389
|
-
const ms = new Date(raw).getTime();
|
|
390
|
-
return Number.isFinite(ms) ? ms : null;
|
|
391
|
-
}
|
|
392
|
-
return null;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/** True when the token expires within the given buffer (default 60s). */
|
|
396
|
-
export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
|
|
397
|
-
const expiresAt = parseExpiresAtMs(tokens.expiresAt);
|
|
398
|
-
if (expiresAt === null) return true;
|
|
399
|
-
return expiresAt - Date.now() < bufferSeconds * 1000;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Decode the `client_id` claim from a Cognito access token (no signature
|
|
404
|
-
* verification — we only need to identify which App Client minted it).
|
|
405
|
-
* Returns null when the token can't be parsed.
|
|
406
|
-
*
|
|
407
|
-
* Used by `getValidAccessToken` to detect stale cached sessions that target
|
|
408
|
-
* a different Cognito App Client. The canonical case is a pre-2026-04-25
|
|
409
|
-
* cache file holding a `hq-vault-dev` token after the user upgraded to a
|
|
410
|
-
* post-cutover CLI: the access token stays "non-expiring" for an hour but
|
|
411
|
-
* the prod vault API rejects it with 401, and the dev refresh token can't
|
|
412
|
-
* be exchanged at the prod token endpoint. Detecting the mismatch and
|
|
413
|
-
* forcing a re-login is the only safe self-heal.
|
|
414
|
-
*/
|
|
415
|
-
export function decodeAccessTokenClientId(accessToken: string): string | null {
|
|
416
|
-
const claims = decodeJwtClaims(accessToken);
|
|
417
|
-
return typeof claims?.client_id === "string" ? claims.client_id : null;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function decodeJwtClaims(token: string): Record<string, unknown> | null {
|
|
421
|
-
try {
|
|
422
|
-
const parts = token.split(".");
|
|
423
|
-
if (parts.length < 2) return null;
|
|
424
|
-
const payloadB64 = parts[1];
|
|
425
|
-
const normalized = payloadB64.replace(/-/g, "+").replace(/_/g, "/");
|
|
426
|
-
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
427
|
-
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
428
|
-
const claims = JSON.parse(json);
|
|
429
|
-
return claims && typeof claims === "object" && !Array.isArray(claims)
|
|
430
|
-
? (claims as Record<string, unknown>)
|
|
431
|
-
: null;
|
|
432
|
-
} catch {
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function machineTokenPairMatchesIdentity(
|
|
438
|
-
tokens: CognitoTokens,
|
|
439
|
-
creds: MachineCreds,
|
|
440
|
-
expectedClientId: string,
|
|
441
|
-
): boolean {
|
|
442
|
-
const accessClaims = decodeJwtClaims(tokens.accessToken);
|
|
443
|
-
const idClaims = decodeJwtClaims(tokens.idToken);
|
|
444
|
-
if (!accessClaims || !idClaims) return false;
|
|
445
|
-
|
|
446
|
-
const stringClaim = (
|
|
447
|
-
claims: Record<string, unknown>,
|
|
448
|
-
key: string,
|
|
449
|
-
): string | null => {
|
|
450
|
-
const value = claims[key];
|
|
451
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
const accessClientId = accessClaims.client_id;
|
|
455
|
-
const idAudience = idClaims.aud;
|
|
456
|
-
const accessUsername =
|
|
457
|
-
stringClaim(accessClaims, "username") ??
|
|
458
|
-
stringClaim(accessClaims, "cognito:username");
|
|
459
|
-
const accessSub = stringClaim(accessClaims, "sub");
|
|
460
|
-
const idUsername =
|
|
461
|
-
stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
|
|
462
|
-
const idSub = stringClaim(idClaims, "sub");
|
|
463
|
-
const entityType = stringClaim(idClaims, "custom:entityType");
|
|
464
|
-
const entityUid = stringClaim(idClaims, "custom:entityUid");
|
|
465
|
-
const delegatedSub = stringClaim(idClaims, "custom:delegatedSub");
|
|
466
|
-
const delegatedEmail = stringClaim(idClaims, "custom:delegatedEmail");
|
|
467
|
-
const hasValidMachineClaimShape =
|
|
468
|
-
(entityType === "agent" &&
|
|
469
|
-
entityUid !== null &&
|
|
470
|
-
entityUid.startsWith("agt_")) ||
|
|
471
|
-
(entityType === "outpost" &&
|
|
472
|
-
entityUid !== null &&
|
|
473
|
-
entityUid.startsWith("out_") &&
|
|
474
|
-
delegatedSub !== null &&
|
|
475
|
-
delegatedEmail !== null);
|
|
476
|
-
// Legacy agent credentials did not persist entityType. If a newer creds
|
|
477
|
-
// file adds only its agt_* uid, infer the only valid type for that uid.
|
|
478
|
-
const expectedEntityType = creds.entityType ?? "agent";
|
|
479
|
-
const entityMatchesCreds =
|
|
480
|
-
entityType === expectedEntityType &&
|
|
481
|
-
(creds.entityUid === undefined || entityUid === creds.entityUid);
|
|
482
|
-
// Bind the ID token to the REQUESTED credentials. Username equality only
|
|
483
|
-
// holds in pools without email aliasing; the prod pool aliases email-format
|
|
484
|
-
// usernames, so Cognito's canonical username (`cognito:username`, access
|
|
485
|
-
// `username`, `sub`) is a generated UUID that NEVER equals the email-format
|
|
486
|
-
// creds.username — and the machine app client cannot read `email`, so no
|
|
487
|
-
// email claim rides the ID token either. For creds that persist their
|
|
488
|
-
// entityUid (every ADR-0009 outpost and modern agent), the unique
|
|
489
|
-
// custom:entityUid claim IS the identity binding: exactly one Cognito user
|
|
490
|
-
// carries that attribute value, stamped server-side at provisioning.
|
|
491
|
-
// Regression: 6.14.13 shipped username-only binding and rejected every
|
|
492
|
-
// valid machine mint in the alias pool ("Machine token mint returned tokens
|
|
493
|
-
// that do not match the requested machine identity"), logging out the
|
|
494
|
-
// outpost fleet. Legacy creds without entityUid keep the username binding.
|
|
495
|
-
const idTokenMatchesCreds =
|
|
496
|
-
idUsername === creds.username ||
|
|
497
|
-
idSub === creds.username ||
|
|
498
|
-
(creds.entityUid !== undefined && entityUid === creds.entityUid);
|
|
499
|
-
const subjectBindings: boolean[] = [];
|
|
500
|
-
if (accessUsername !== null && idUsername !== null) {
|
|
501
|
-
subjectBindings.push(accessUsername === idUsername);
|
|
502
|
-
}
|
|
503
|
-
if (accessSub !== null && idSub !== null) {
|
|
504
|
-
subjectBindings.push(accessSub === idSub);
|
|
505
|
-
}
|
|
506
|
-
const tokensShareSubject =
|
|
507
|
-
subjectBindings.length > 0 && subjectBindings.every(Boolean);
|
|
508
|
-
|
|
509
|
-
return (
|
|
510
|
-
accessClaims.token_use === "access" &&
|
|
511
|
-
accessClientId === expectedClientId &&
|
|
512
|
-
idClaims.token_use === "id" &&
|
|
513
|
-
idAudience === expectedClientId &&
|
|
514
|
-
hasValidMachineClaimShape &&
|
|
515
|
-
entityMatchesCreds &&
|
|
516
|
-
idTokenMatchesCreds &&
|
|
517
|
-
tokensShareSubject
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// ---------------------------------------------------------------------------
|
|
522
|
-
// Machine identity (company agents and Outpost boxes)
|
|
523
|
-
// ---------------------------------------------------------------------------
|
|
524
|
-
//
|
|
525
|
-
// HQ agents and Outposts run headless on their own boxes with long-lived Cognito
|
|
526
|
-
// MACHINE credentials ({username: "machine-agt_<ulid>", secret}) provisioned
|
|
527
|
-
// by hq-pro's agent bootstrap and stored at ~/.hq-agent/machine-creds.json.
|
|
528
|
-
// There is no browser, no Hosted UI, and no refresh-token dance: the creds
|
|
529
|
-
// never expire, so the CLI simply re-mints a session via USER_PASSWORD_AUTH
|
|
530
|
-
// whenever the cached tokens are missing or expiring.
|
|
531
|
-
//
|
|
532
|
-
// Token semantics matter here. Machine identity claims ride the ID token only:
|
|
533
|
-
// agents carry custom:entityType=agent/custom:entityUid=agt_*, while Outposts
|
|
534
|
-
// carry outpost/out_* plus delegatedSub and delegatedEmail. APIs that verify
|
|
535
|
-
// token_use=access (e.g. hq-deploy) need the real access token. Both are cached
|
|
536
|
-
// with correct field semantics — callers pick the token type each API validates.
|
|
537
|
-
|
|
538
|
-
export interface MachineCreds {
|
|
539
|
-
/** Cognito username for the machine user. */
|
|
540
|
-
username: string;
|
|
541
|
-
/** Long-lived machine secret (USER_PASSWORD_AUTH password). */
|
|
542
|
-
secret: string;
|
|
543
|
-
/** App client to mint against — provisioned creds carry their own
|
|
544
|
-
* (USER_PASSWORD_AUTH must be enabled on it); falls back to config. */
|
|
545
|
-
clientId?: string;
|
|
546
|
-
/** Cognito region for the mint endpoint; falls back to config. */
|
|
547
|
-
region?: string;
|
|
548
|
-
/** Expected machine kind carried in the ID token's custom:entityType claim. */
|
|
549
|
-
entityType?: "agent" | "outpost";
|
|
550
|
-
/** Exact expected machine uid carried in custom:entityUid. */
|
|
551
|
-
entityUid?: string;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/** Resolve the machine-creds file path (HQ_MACHINE_CREDS_FILE overrides). */
|
|
555
|
-
export function machineCredsFilePath(): string {
|
|
556
|
-
return (
|
|
557
|
-
process.env.HQ_MACHINE_CREDS_FILE ??
|
|
558
|
-
path.join(os.homedir(), ".hq-agent", "machine-creds.json")
|
|
559
|
-
);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Load machine credentials, or null when this process is not running as a
|
|
564
|
-
* machine identity (no creds file / unreadable / malformed).
|
|
565
|
-
*/
|
|
566
|
-
export function loadMachineCreds(): MachineCreds | null {
|
|
567
|
-
const file = machineCredsFilePath();
|
|
568
|
-
try {
|
|
569
|
-
if (!fs.existsSync(file)) return null;
|
|
570
|
-
const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as {
|
|
571
|
-
username?: unknown;
|
|
572
|
-
secret?: unknown;
|
|
573
|
-
clientId?: unknown;
|
|
574
|
-
region?: unknown;
|
|
575
|
-
entityType?: unknown;
|
|
576
|
-
entityUid?: unknown;
|
|
577
|
-
};
|
|
578
|
-
// The creds FILE is the machine-identity signal — no username-shape
|
|
579
|
-
// gate. (6.7.0 required a "machine-" prefix, but live boxes are
|
|
580
|
-
// provisioned with "agt-<ulid>@agents.getindigo.ai", so detection
|
|
581
|
-
// never engaged on the first real agent box, 2026-06-12.)
|
|
582
|
-
const validEntityIdentity =
|
|
583
|
-
(raw.entityType === undefined && raw.entityUid === undefined) ||
|
|
584
|
-
(raw.entityType === undefined &&
|
|
585
|
-
typeof raw.entityUid === "string" &&
|
|
586
|
-
raw.entityUid.startsWith("agt_") &&
|
|
587
|
-
raw.entityUid.length > "agt_".length) ||
|
|
588
|
-
(raw.entityType === "agent" &&
|
|
589
|
-
typeof raw.entityUid === "string" &&
|
|
590
|
-
raw.entityUid.startsWith("agt_") &&
|
|
591
|
-
raw.entityUid.length > "agt_".length) ||
|
|
592
|
-
(raw.entityType === "outpost" &&
|
|
593
|
-
typeof raw.entityUid === "string" &&
|
|
594
|
-
raw.entityUid.startsWith("out_") &&
|
|
595
|
-
raw.entityUid.length > "out_".length);
|
|
596
|
-
if (
|
|
597
|
-
typeof raw.username === "string" &&
|
|
598
|
-
raw.username.length > 0 &&
|
|
599
|
-
typeof raw.secret === "string" &&
|
|
600
|
-
raw.secret.length > 0 &&
|
|
601
|
-
validEntityIdentity
|
|
602
|
-
) {
|
|
603
|
-
return {
|
|
604
|
-
username: raw.username,
|
|
605
|
-
secret: raw.secret,
|
|
606
|
-
clientId: typeof raw.clientId === "string" ? raw.clientId : undefined,
|
|
607
|
-
region: typeof raw.region === "string" ? raw.region : undefined,
|
|
608
|
-
...(raw.entityType === "agent" || raw.entityType === "outpost"
|
|
609
|
-
? { entityType: raw.entityType }
|
|
610
|
-
: {}),
|
|
611
|
-
...(typeof raw.entityUid === "string"
|
|
612
|
-
? { entityUid: raw.entityUid }
|
|
613
|
-
: {}),
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
return null;
|
|
617
|
-
} catch {
|
|
618
|
-
return null;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/** True when machine credentials are present — the CLI is a machine identity. */
|
|
623
|
-
export function isMachineIdentity(): boolean {
|
|
624
|
-
return loadMachineCreds() !== null;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Whether the caller declared this process MUST run as a machine identity
|
|
629
|
-
* (`HQ_REQUIRE_MACHINE_IDENTITY=1`).
|
|
630
|
-
*
|
|
631
|
-
* Set it on unattended runners — child jobs, workers, skill subprocesses. It
|
|
632
|
-
* converts the worst failure mode in this module into a clear one.
|
|
633
|
-
*
|
|
634
|
-
* Without it, a machine context that cannot find its credentials does not
|
|
635
|
-
* error: `isMachineIdentity()` returns false and the caller quietly proceeds to
|
|
636
|
-
* the HUMAN Cognito path. On a headless box that means "launching browser
|
|
637
|
-
* sign-in" and a hang until something times out — observed in production, and
|
|
638
|
-
* reported by a client as child jobs "falling back to refreshing a human HQ
|
|
639
|
-
* session". The cause (a different HOME, so a different creds path) is nowhere
|
|
640
|
-
* in that message.
|
|
641
|
-
*/
|
|
642
|
-
export function machineIdentityRequired(): boolean {
|
|
643
|
-
const raw = process.env.HQ_REQUIRE_MACHINE_IDENTITY?.trim().toLowerCase();
|
|
644
|
-
return raw === "1" || raw === "true" || raw === "yes";
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Throw a typed, diagnosable error when machine identity is REQUIRED but the
|
|
649
|
-
* creds file is not readable. Names the exact path checked and how it was
|
|
650
|
-
* derived, so the fix (set `HQ_MACHINE_CREDS_FILE`, or restore HOME) is
|
|
651
|
-
* readable straight off the error. No-op when not required or already a
|
|
652
|
-
* machine identity.
|
|
653
|
-
*/
|
|
654
|
-
export function assertMachineIdentityWhenRequired(): void {
|
|
655
|
-
if (!machineIdentityRequired() || isMachineIdentity()) return;
|
|
656
|
-
const explicit = process.env.HQ_MACHINE_CREDS_FILE?.trim();
|
|
657
|
-
const source =
|
|
658
|
-
explicit !== undefined && explicit.length > 0
|
|
659
|
-
? "HQ_MACHINE_CREDS_FILE"
|
|
660
|
-
: `os.homedir()=${os.homedir()}`;
|
|
661
|
-
throw new CognitoAuthError(
|
|
662
|
-
`HQ_REQUIRE_MACHINE_IDENTITY is set but no usable machine credentials were ` +
|
|
663
|
-
`found at ${machineCredsFilePath()} (path from ${source}). Refusing to ` +
|
|
664
|
-
`fall back to an interactive human session. Set HQ_MACHINE_CREDS_FILE to ` +
|
|
665
|
-
`the credentials file, or run with the HOME that owns it.`,
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
interface InitiateAuthResponse {
|
|
670
|
-
AuthenticationResult?: {
|
|
671
|
-
AccessToken?: string;
|
|
672
|
-
IdToken?: string;
|
|
673
|
-
RefreshToken?: string;
|
|
674
|
-
ExpiresIn?: number;
|
|
675
|
-
};
|
|
676
|
-
ChallengeName?: string;
|
|
677
|
-
__type?: string;
|
|
678
|
-
message?: string;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/** Tunables for the machine-mint retry-on-throttle loop. Defaults are the
|
|
682
|
-
* production values; tests pass `baseDelayMs: 0` to retry without waiting. */
|
|
683
|
-
export interface MintRetryOptions {
|
|
684
|
-
/** Retries AFTER the first attempt (so total attempts = maxRetries + 1). */
|
|
685
|
-
maxRetries?: number;
|
|
686
|
-
/** Backoff for the first retry in ms; doubles each subsequent attempt. */
|
|
687
|
-
baseDelayMs?: number;
|
|
688
|
-
/** Ceiling for any single backoff sleep. */
|
|
689
|
-
maxDelayMs?: number;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
const DEFAULT_MINT_RETRY: Required<MintRetryOptions> = {
|
|
693
|
-
maxRetries: 5,
|
|
694
|
-
baseDelayMs: 250,
|
|
695
|
-
maxDelayMs: 5000,
|
|
696
|
-
};
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Cognito signals a per-user mint throttle as `TooManyRequestsException` — and
|
|
700
|
-
* (observed in prod on the Infraredi agent box, 2026-06-26) returns it under
|
|
701
|
-
* HTTP 400, not 429. Match the typed error first and the status second so both
|
|
702
|
-
* shapes are treated as transient throttles to retry, not hard auth failures.
|
|
703
|
-
*/
|
|
704
|
-
function isCognitoThrottle(status: number, type: string | undefined): boolean {
|
|
705
|
-
if (typeof type === "string" && type.includes("TooManyRequests")) return true;
|
|
706
|
-
return status === 429;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
function sleep(ms: number): Promise<void> {
|
|
710
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/** Full-jitter exponential backoff: a random delay in
|
|
714
|
-
* [0, min(maxDelayMs, baseDelayMs * 2^attempt)]. Jitter de-syncs a concurrent
|
|
715
|
-
* burst of retries so they don't re-collide on the same per-user limit. */
|
|
716
|
-
function backoffDelayMs(
|
|
717
|
-
attempt: number,
|
|
718
|
-
opts: Required<MintRetryOptions>,
|
|
719
|
-
): number {
|
|
720
|
-
const ceiling = Math.min(opts.maxDelayMs, opts.baseDelayMs * 2 ** attempt);
|
|
721
|
-
return Math.floor(Math.random() * (ceiling + 1));
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/**
|
|
725
|
-
* Mint a fresh session for the machine identity via USER_PASSWORD_AUTH
|
|
726
|
-
* against the Cognito IDP endpoint (plain unsigned HTTP — no AWS SDK
|
|
727
|
-
* dependency). Caches BOTH tokens with correct field semantics and returns
|
|
728
|
-
* them.
|
|
729
|
-
*
|
|
730
|
-
* Retries on a `TooManyRequestsException` throttle with exponential backoff +
|
|
731
|
-
* full jitter (transient, per-user rate limit); every other non-OK response
|
|
732
|
-
* (bad creds, disabled flow) is a hard error surfaced immediately.
|
|
733
|
-
*/
|
|
734
|
-
async function mintMachineTokensUnlocked(
|
|
735
|
-
config: CognitoAuthConfig,
|
|
736
|
-
creds?: MachineCreds,
|
|
737
|
-
retry?: MintRetryOptions,
|
|
738
|
-
): Promise<CognitoTokens> {
|
|
739
|
-
const machineCreds = creds ?? loadMachineCreds();
|
|
740
|
-
if (!machineCreds) {
|
|
741
|
-
throw new CognitoAuthError(
|
|
742
|
-
`No machine credentials found at ${machineCredsFilePath()}`,
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
// Prefer the creds file's own clientId/region: provisioning pairs the
|
|
746
|
-
// machine user with an app client that has USER_PASSWORD_AUTH enabled,
|
|
747
|
-
// which the CLI's default (browser) client may not.
|
|
748
|
-
const region = machineCreds.region ?? config.region;
|
|
749
|
-
const clientId = machineCreds.clientId ?? config.clientId;
|
|
750
|
-
const retryOpts: Required<MintRetryOptions> = { ...DEFAULT_MINT_RETRY, ...retry };
|
|
751
|
-
|
|
752
|
-
let lastError: CognitoAuthError | null = null;
|
|
753
|
-
for (let attempt = 0; attempt <= retryOpts.maxRetries; attempt++) {
|
|
754
|
-
const res = await fetch(
|
|
755
|
-
`https://cognito-idp.${region}.amazonaws.com/`,
|
|
756
|
-
{
|
|
757
|
-
method: "POST",
|
|
758
|
-
headers: {
|
|
759
|
-
"Content-Type": "application/x-amz-json-1.1",
|
|
760
|
-
"X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
|
|
761
|
-
},
|
|
762
|
-
body: JSON.stringify({
|
|
763
|
-
AuthFlow: "USER_PASSWORD_AUTH",
|
|
764
|
-
ClientId: clientId,
|
|
765
|
-
AuthParameters: {
|
|
766
|
-
USERNAME: machineCreds.username,
|
|
767
|
-
PASSWORD: machineCreds.secret,
|
|
768
|
-
},
|
|
769
|
-
}),
|
|
770
|
-
},
|
|
771
|
-
);
|
|
772
|
-
const data = (await res.json().catch(() => ({}))) as InitiateAuthResponse;
|
|
773
|
-
if (!res.ok) {
|
|
774
|
-
const err = new CognitoAuthError(
|
|
775
|
-
`Machine token mint failed (${res.status}): ${data.__type ?? ""} ${data.message ?? ""}`.trim(),
|
|
776
|
-
);
|
|
777
|
-
// Back off and retry a transient per-user throttle rather than failing
|
|
778
|
-
// the whole sync run; surface every other auth error immediately.
|
|
779
|
-
if (
|
|
780
|
-
isCognitoThrottle(res.status, data.__type) &&
|
|
781
|
-
attempt < retryOpts.maxRetries
|
|
782
|
-
) {
|
|
783
|
-
lastError = err;
|
|
784
|
-
await sleep(backoffDelayMs(attempt, retryOpts));
|
|
785
|
-
continue;
|
|
786
|
-
}
|
|
787
|
-
throw err;
|
|
788
|
-
}
|
|
789
|
-
const result = data.AuthenticationResult;
|
|
790
|
-
if (!result?.AccessToken || !result?.IdToken) {
|
|
791
|
-
throw new CognitoAuthError(
|
|
792
|
-
`Machine token mint returned no tokens${data.ChallengeName ? ` (challenge: ${data.ChallengeName})` : ""}`,
|
|
793
|
-
);
|
|
794
|
-
}
|
|
795
|
-
const tokens: CognitoTokens = {
|
|
796
|
-
accessToken: result.AccessToken,
|
|
797
|
-
idToken: result.IdToken,
|
|
798
|
-
// Machine creds never expire — expiry is handled by re-minting, so the
|
|
799
|
-
// refresh token (when Cognito returns one at all) is never retained.
|
|
800
|
-
refreshToken: "",
|
|
801
|
-
expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
|
|
802
|
-
tokenType: "Bearer",
|
|
803
|
-
};
|
|
804
|
-
if (!machineTokenPairMatchesIdentity(tokens, machineCreds, clientId)) {
|
|
805
|
-
throw new CognitoAuthError(
|
|
806
|
-
"Machine token mint returned tokens that do not match the requested machine identity",
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
saveCachedTokens(tokens);
|
|
810
|
-
return tokens;
|
|
811
|
-
}
|
|
812
|
-
// Unreachable in practice — the final throttled attempt throws inside the
|
|
813
|
-
// loop — but keeps the function total for the type checker.
|
|
814
|
-
throw lastError ?? new CognitoAuthError("Machine token mint failed: throttled");
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* Mint and persist a fresh machine session while holding the process-shared
|
|
819
|
-
* token lock. The unlocked implementation is private so public callers cannot
|
|
820
|
-
* race another process's cache write.
|
|
821
|
-
*/
|
|
822
|
-
export async function mintMachineTokens(
|
|
823
|
-
config: CognitoAuthConfig,
|
|
824
|
-
creds?: MachineCreds,
|
|
825
|
-
retry?: MintRetryOptions,
|
|
826
|
-
): Promise<CognitoTokens> {
|
|
827
|
-
return withRefreshLock(() => mintMachineTokensUnlocked(config, creds, retry));
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// In-process machine-token memo (sync-runner mint-storm fix).
|
|
831
|
-
//
|
|
832
|
-
// `getValidAccessToken` runs on EVERY vault request, and a busy sync fans out
|
|
833
|
-
// many requests concurrently (presign primes, STS vend, the per-file tombstone
|
|
834
|
-
// HEAD verifies). The on-disk cache alone does NOT bound mints: at run start
|
|
835
|
-
// the disk token is often within its expiry buffer, so N concurrent callers
|
|
836
|
-
// each independently observe "expiring" and each fire USER_PASSWORD_AUTH —
|
|
837
|
-
// bursting Cognito's per-user limit (TooManyRequestsException) on large vaults
|
|
838
|
-
// (observed: 56 throttled mints over 314 files on the Infraredi box, 2026-06-26).
|
|
839
|
-
//
|
|
840
|
-
// This module-level memo makes mints O(1) per process:
|
|
841
|
-
// - hot path: a live in-memory token serves every later request with no disk
|
|
842
|
-
// read and no network — a 300-file sync does ONE mint, not one-per-request;
|
|
843
|
-
// - single-flight: a concurrent burst of cache-misses (the run-start fan-out)
|
|
844
|
-
// collapses onto a single in-flight mint instead of one mint per caller.
|
|
845
|
-
// The memo carries an exact credential/client key, and in-flight work is keyed,
|
|
846
|
-
// so reprovisioning a long-running process cannot reuse the prior identity.
|
|
847
|
-
interface MachineTokenMemo {
|
|
848
|
-
identityKey: string;
|
|
849
|
-
tokens: CognitoTokens;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
let inProcessMachineTokens: MachineTokenMemo | null = null;
|
|
853
|
-
const inFlightMachineMints = new Map<string, Promise<CognitoTokens>>();
|
|
854
|
-
|
|
855
|
-
function machineIdentityKey(
|
|
856
|
-
config: CognitoAuthConfig,
|
|
857
|
-
creds: MachineCreds,
|
|
858
|
-
): string {
|
|
859
|
-
// Include every credential value that can change the effective mint. Hashing
|
|
860
|
-
// keeps the long-lived secret out of diagnostic heap strings while retaining
|
|
861
|
-
// exact-key behavior when a creds file is reprovisioned in place.
|
|
862
|
-
return crypto
|
|
863
|
-
.createHash("sha256")
|
|
864
|
-
.update(
|
|
865
|
-
JSON.stringify({
|
|
866
|
-
username: creds.username,
|
|
867
|
-
secret: creds.secret,
|
|
868
|
-
clientId: creds.clientId ?? config.clientId,
|
|
869
|
-
region: creds.region ?? config.region,
|
|
870
|
-
entityType: creds.entityType ?? "agent",
|
|
871
|
-
entityUid: creds.entityUid ?? null,
|
|
872
|
-
}),
|
|
873
|
-
)
|
|
874
|
-
.digest("hex");
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* Return a valid (non-expiring) machine session, re-minting on demand.
|
|
879
|
-
* Cache-hit path never touches the network — and after the first mint, the
|
|
880
|
-
* in-process memo serves every subsequent request for the life of the token.
|
|
881
|
-
*/
|
|
882
|
-
export async function getValidMachineTokens(
|
|
883
|
-
config: CognitoAuthConfig,
|
|
884
|
-
): Promise<CognitoTokens> {
|
|
885
|
-
const machineCreds = loadMachineCreds();
|
|
886
|
-
if (!machineCreds) {
|
|
887
|
-
throw new CognitoAuthError(
|
|
888
|
-
`No machine credentials found at ${machineCredsFilePath()}`,
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
const expectedClientId = machineCreds.clientId ?? config.clientId;
|
|
892
|
-
const identityKey = machineIdentityKey(config, machineCreds);
|
|
893
|
-
// Hot path: a live in-memory token serves every request after the first.
|
|
894
|
-
if (
|
|
895
|
-
inProcessMachineTokens?.identityKey === identityKey &&
|
|
896
|
-
!isExpiring(inProcessMachineTokens.tokens, 120) &&
|
|
897
|
-
machineTokenPairMatchesIdentity(
|
|
898
|
-
inProcessMachineTokens.tokens,
|
|
899
|
-
machineCreds,
|
|
900
|
-
expectedClientId,
|
|
901
|
-
)
|
|
902
|
-
) {
|
|
903
|
-
return inProcessMachineTokens.tokens;
|
|
904
|
-
}
|
|
905
|
-
// Single-flight: collapse a concurrent burst of misses onto one resolution.
|
|
906
|
-
// The assignment below completes before the first `await` yields, so sibling
|
|
907
|
-
// callers in the same tick see the in-flight promise and reuse it.
|
|
908
|
-
const existingResolution = inFlightMachineMints.get(identityKey);
|
|
909
|
-
if (existingResolution) return existingResolution;
|
|
910
|
-
|
|
911
|
-
const resolution = (async () => {
|
|
912
|
-
const resolveUnderLock = async (): Promise<CognitoTokens> => {
|
|
913
|
-
// Re-read only after acquiring the process-shared lock. A different hq
|
|
914
|
-
// process may have minted and saved this machine's session while this
|
|
915
|
-
// caller was waiting.
|
|
916
|
-
const cached = loadCachedTokens();
|
|
917
|
-
if (cached && !isExpiring(cached, 120)) {
|
|
918
|
-
// Compare against the client we'd actually mint with (creds-file
|
|
919
|
-
// clientId wins over config), and require the cached ID token to prove
|
|
920
|
-
// the exact machine identity. Opaque/missing/human-shaped claims are
|
|
921
|
-
// treated as stale.
|
|
922
|
-
if (
|
|
923
|
-
machineTokenPairMatchesIdentity(
|
|
924
|
-
cached,
|
|
925
|
-
machineCreds,
|
|
926
|
-
expectedClientId,
|
|
927
|
-
)
|
|
928
|
-
) {
|
|
929
|
-
if (cached.refreshToken === "") return cached;
|
|
930
|
-
const normalized = { ...cached, refreshToken: "" };
|
|
931
|
-
saveCachedTokens(normalized);
|
|
932
|
-
return normalized;
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
// mintMachineTokens persists before returning, so the complete shared
|
|
936
|
-
// cache read -> USER_PASSWORD_AUTH mint -> save sequence is serialized.
|
|
937
|
-
return mintMachineTokensUnlocked(config, machineCreds);
|
|
938
|
-
};
|
|
939
|
-
|
|
940
|
-
try {
|
|
941
|
-
return await withRefreshLock(resolveUnderLock);
|
|
942
|
-
} catch (err) {
|
|
943
|
-
if (!(err instanceof RefreshLockTimeoutError)) throw err;
|
|
944
|
-
// Never mint unlocked. If the holder managed to persist a matching fresh
|
|
945
|
-
// session, reuse it; otherwise surface a retryable auth failure.
|
|
946
|
-
const cached = loadCachedTokens();
|
|
947
|
-
if (cached && !isExpiring(cached, 120)) {
|
|
948
|
-
if (
|
|
949
|
-
machineTokenPairMatchesIdentity(
|
|
950
|
-
cached,
|
|
951
|
-
machineCreds,
|
|
952
|
-
expectedClientId,
|
|
953
|
-
)
|
|
954
|
-
) {
|
|
955
|
-
// The successful path always leaves a machine cache with no refresh
|
|
956
|
-
// token. If this is legacy state, only normalize it while holding the
|
|
957
|
-
// lock; after a timeout we must not race the live holder's write.
|
|
958
|
-
if (cached.refreshToken === "") return cached;
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
throw new CognitoAuthError(
|
|
962
|
-
"Machine token mint is held by another process and no fresh matching session is available; retry shortly.",
|
|
963
|
-
);
|
|
964
|
-
}
|
|
965
|
-
})();
|
|
966
|
-
inFlightMachineMints.set(identityKey, resolution);
|
|
967
|
-
|
|
968
|
-
try {
|
|
969
|
-
const tokens = await resolution;
|
|
970
|
-
inProcessMachineTokens = { identityKey, tokens };
|
|
971
|
-
return tokens;
|
|
972
|
-
} finally {
|
|
973
|
-
// Clear on both success and failure: a rejected mint must not wedge later
|
|
974
|
-
// calls onto a permanently-failed promise.
|
|
975
|
-
if (inFlightMachineMints.get(identityKey) === resolution) {
|
|
976
|
-
inFlightMachineMints.delete(identityKey);
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
// ---------------------------------------------------------------------------
|
|
982
|
-
// PKCE
|
|
983
|
-
// ---------------------------------------------------------------------------
|
|
984
|
-
|
|
985
|
-
function base64UrlEncode(buf: Buffer): string {
|
|
986
|
-
return buf
|
|
987
|
-
.toString("base64")
|
|
988
|
-
.replace(/\+/g, "-")
|
|
989
|
-
.replace(/\//g, "_")
|
|
990
|
-
.replace(/=+$/, "");
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
function generatePkce(): { verifier: string; challenge: string } {
|
|
994
|
-
const verifier = base64UrlEncode(crypto.randomBytes(32));
|
|
995
|
-
const challenge = base64UrlEncode(
|
|
996
|
-
crypto.createHash("sha256").update(verifier).digest(),
|
|
997
|
-
);
|
|
998
|
-
return { verifier, challenge };
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// ---------------------------------------------------------------------------
|
|
1002
|
-
// Endpoint helpers
|
|
1003
|
-
// ---------------------------------------------------------------------------
|
|
1004
|
-
|
|
1005
|
-
function authBaseUrl(config: CognitoAuthConfig): string {
|
|
1006
|
-
return `https://${config.userPoolDomain}.auth.${config.region}.amazoncognito.com`;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
function redirectUri(port: number): string {
|
|
1010
|
-
return `http://localhost:${port}/callback`;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// ---------------------------------------------------------------------------
|
|
1014
|
-
// Browser login
|
|
1015
|
-
// ---------------------------------------------------------------------------
|
|
1016
|
-
|
|
1017
|
-
/**
|
|
1018
|
-
* Open the Cognito Hosted UI in the user's browser, wait for the redirect
|
|
1019
|
-
* back to localhost, and exchange the auth code for tokens.
|
|
1020
|
-
*
|
|
1021
|
-
* Times out after 5 minutes if the user doesn't complete the flow.
|
|
1022
|
-
*/
|
|
1023
|
-
export async function browserLogin(
|
|
1024
|
-
config: CognitoAuthConfig,
|
|
1025
|
-
): Promise<CognitoTokens> {
|
|
1026
|
-
const port = config.port ?? 3000;
|
|
1027
|
-
const scopes = (config.scopes ?? ["openid", "email", "profile"]).join(" ");
|
|
1028
|
-
const { verifier, challenge } = generatePkce();
|
|
1029
|
-
const state = base64UrlEncode(crypto.randomBytes(16));
|
|
1030
|
-
|
|
1031
|
-
// Use `/oauth2/authorize` (not `/login`) so `identity_provider` + `prompt`
|
|
1032
|
-
// are honored. `/login` ignores those params and always shows the IdP picker.
|
|
1033
|
-
const authUrl = new URL(`${authBaseUrl(config)}/oauth2/authorize`);
|
|
1034
|
-
authUrl.searchParams.set("client_id", config.clientId);
|
|
1035
|
-
authUrl.searchParams.set("response_type", "code");
|
|
1036
|
-
authUrl.searchParams.set("scope", scopes);
|
|
1037
|
-
authUrl.searchParams.set("redirect_uri", redirectUri(port));
|
|
1038
|
-
authUrl.searchParams.set("code_challenge", challenge);
|
|
1039
|
-
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
1040
|
-
authUrl.searchParams.set("state", state);
|
|
1041
|
-
if (config.identityProvider) {
|
|
1042
|
-
authUrl.searchParams.set("identity_provider", config.identityProvider);
|
|
1043
|
-
}
|
|
1044
|
-
if (config.prompt) {
|
|
1045
|
-
authUrl.searchParams.set("prompt", config.prompt);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
const code = await waitForAuthCode(port, state);
|
|
1049
|
-
const tokens = await exchangeCodeForTokens(config, code, verifier, port);
|
|
1050
|
-
saveCachedTokens(tokens);
|
|
1051
|
-
return tokens;
|
|
1052
|
-
|
|
1053
|
-
// -- inner: spin up loopback server and open browser ---------------------
|
|
1054
|
-
function waitForAuthCode(port: number, expectedState: string): Promise<string> {
|
|
1055
|
-
return new Promise((resolve, reject) => {
|
|
1056
|
-
// cleanup() is a function declaration so it can be referenced from the
|
|
1057
|
-
// server callbacks and the timeout closure below before its source
|
|
1058
|
-
// position. It clears the 15-min login timer + closes the loopback
|
|
1059
|
-
// server — without this both keep Node's event loop alive after the
|
|
1060
|
-
// calling script "completes", making it look hung.
|
|
1061
|
-
const server = http.createServer((req, res) => {
|
|
1062
|
-
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
1063
|
-
if (url.pathname !== "/callback") {
|
|
1064
|
-
res.writeHead(404);
|
|
1065
|
-
res.end("Not found");
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
const code = url.searchParams.get("code");
|
|
1069
|
-
const state = url.searchParams.get("state");
|
|
1070
|
-
const error = url.searchParams.get("error");
|
|
1071
|
-
|
|
1072
|
-
if (error) {
|
|
1073
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1074
|
-
res.end(`<h1>Authentication failed</h1><p>${escapeHtml(error)}</p>`);
|
|
1075
|
-
cleanup();
|
|
1076
|
-
reject(new CognitoAuthError(`Cognito returned error: ${error}`));
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
if (state !== expectedState) {
|
|
1080
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1081
|
-
res.end("<h1>State mismatch</h1><p>Possible CSRF — try again.</p>");
|
|
1082
|
-
cleanup();
|
|
1083
|
-
reject(new CognitoAuthError("Cognito state parameter mismatch"));
|
|
1084
|
-
return;
|
|
1085
|
-
}
|
|
1086
|
-
if (!code) {
|
|
1087
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1088
|
-
res.end("<h1>Missing code</h1>");
|
|
1089
|
-
cleanup();
|
|
1090
|
-
reject(new CognitoAuthError("Cognito callback missing code"));
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1095
|
-
res.end(
|
|
1096
|
-
`<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:48px;">
|
|
1097
|
-
<h1>Signed in to HQ by Indigo</h1>
|
|
1098
|
-
<p>You can close this tab and return to your terminal.</p>
|
|
1099
|
-
<script>setTimeout(()=>window.close(),1500)</script>
|
|
1100
|
-
</body></html>`,
|
|
1101
|
-
);
|
|
1102
|
-
cleanup();
|
|
1103
|
-
resolve(code);
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
server.on("error", (err) => {
|
|
1107
|
-
cleanup();
|
|
1108
|
-
reject(err);
|
|
1109
|
-
});
|
|
1110
|
-
server.listen(port, "127.0.0.1", () => {
|
|
1111
|
-
console.log(`\n Opening browser for HQ sign-in...`);
|
|
1112
|
-
console.log(` If your browser doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
1113
|
-
open(authUrl.toString()).catch(() => {
|
|
1114
|
-
/* user can paste the URL manually */
|
|
1115
|
-
});
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
const loginTimer = setTimeout(
|
|
1119
|
-
() => {
|
|
1120
|
-
cleanup();
|
|
1121
|
-
reject(new CognitoAuthError("Login timed out after 15 minutes"));
|
|
1122
|
-
},
|
|
1123
|
-
15 * 60 * 1000,
|
|
1124
|
-
);
|
|
1125
|
-
|
|
1126
|
-
function cleanup() {
|
|
1127
|
-
clearTimeout(loginTimer);
|
|
1128
|
-
server.close();
|
|
1129
|
-
}
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// ---------------------------------------------------------------------------
|
|
1135
|
-
// Token exchange + refresh
|
|
1136
|
-
// ---------------------------------------------------------------------------
|
|
1137
|
-
|
|
1138
|
-
interface CognitoTokenResponse {
|
|
1139
|
-
access_token: string;
|
|
1140
|
-
id_token: string;
|
|
1141
|
-
refresh_token?: string;
|
|
1142
|
-
expires_in: number;
|
|
1143
|
-
token_type: string;
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
async function exchangeCodeForTokens(
|
|
1147
|
-
config: CognitoAuthConfig,
|
|
1148
|
-
code: string,
|
|
1149
|
-
verifier: string,
|
|
1150
|
-
port: number,
|
|
1151
|
-
): Promise<CognitoTokens> {
|
|
1152
|
-
const body = new URLSearchParams({
|
|
1153
|
-
grant_type: "authorization_code",
|
|
1154
|
-
client_id: config.clientId,
|
|
1155
|
-
code,
|
|
1156
|
-
code_verifier: verifier,
|
|
1157
|
-
redirect_uri: redirectUri(port),
|
|
1158
|
-
});
|
|
1159
|
-
|
|
1160
|
-
const res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
|
|
1161
|
-
method: "POST",
|
|
1162
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1163
|
-
body: body.toString(),
|
|
1164
|
-
});
|
|
1165
|
-
if (!res.ok) {
|
|
1166
|
-
const text = await res.text();
|
|
1167
|
-
throw new CognitoAuthError(
|
|
1168
|
-
`Token exchange failed (${res.status}): ${text}`,
|
|
1169
|
-
);
|
|
1170
|
-
}
|
|
1171
|
-
const data = (await res.json()) as CognitoTokenResponse;
|
|
1172
|
-
if (!data.refresh_token) {
|
|
1173
|
-
throw new CognitoAuthError(
|
|
1174
|
-
"Cognito did not return a refresh token — check OAuth scopes include offline_access semantics",
|
|
1175
|
-
);
|
|
1176
|
-
}
|
|
1177
|
-
return {
|
|
1178
|
-
accessToken: data.access_token,
|
|
1179
|
-
idToken: data.id_token,
|
|
1180
|
-
refreshToken: data.refresh_token,
|
|
1181
|
-
expiresAt: Date.now() + data.expires_in * 1000,
|
|
1182
|
-
tokenType: "Bearer",
|
|
1183
|
-
};
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
/** Whether the cached session was minted by `config`'s app client. */
|
|
1187
|
-
function cachedIsForClient(
|
|
1188
|
-
cached: CognitoTokens,
|
|
1189
|
-
config: CognitoAuthConfig,
|
|
1190
|
-
): boolean {
|
|
1191
|
-
const clientId = decodeAccessTokenClientId(cached.accessToken);
|
|
1192
|
-
return clientId !== null && clientId === config.clientId;
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
/**
|
|
1196
|
-
* Use the refresh token to obtain a fresh access token without user interaction.
|
|
1197
|
-
*
|
|
1198
|
-
* Concurrency-safe. Several hq processes on a box refresh the same cached
|
|
1199
|
-
* session at once, so the whole read→refresh→save cycle runs under a
|
|
1200
|
-
* cross-process lock, and inside the lock we (a) reuse a token another refresher
|
|
1201
|
-
* just produced if it's still fresh, and (b) otherwise refresh with the FRESHEST
|
|
1202
|
-
* token on disk rather than the caller's snapshot. This keeps the shared cache
|
|
1203
|
-
* consistent, and stays correct if the app client uses single-use (rotated)
|
|
1204
|
-
* tokens — a stale snapshot would otherwise be an already-consumed token
|
|
1205
|
-
* (`invalid_grant`). NOTE: rotation is NOT how boxes stay signed in — a rotated
|
|
1206
|
-
* token inherits the original's remaining lifetime, so it does not extend the
|
|
1207
|
-
* session (AWS). Durable never-log-out is per-box machine identity (ADR-0009).
|
|
1208
|
-
*/
|
|
1209
|
-
export async function refreshTokens(
|
|
1210
|
-
config: CognitoAuthConfig,
|
|
1211
|
-
currentRefreshToken: string,
|
|
1212
|
-
currentAccessToken?: string,
|
|
1213
|
-
): Promise<CognitoTokens> {
|
|
1214
|
-
const doRefresh = async (): Promise<CognitoTokens> => {
|
|
1215
|
-
const cached = loadCachedTokens();
|
|
1216
|
-
// Reuse a token another refresher just produced — but ONLY when it belongs
|
|
1217
|
-
// to THIS app client (a direct caller may target a different pool than the
|
|
1218
|
-
// shared cache holds) and is still fresh (a fresh access token has ~1h, so
|
|
1219
|
-
// >300s of life means "someone already refreshed", not the caller's own
|
|
1220
|
-
// expiring token).
|
|
1221
|
-
if (cached && cachedIsForClient(cached, config) && !isExpiring(cached, 300)) {
|
|
1222
|
-
return cached;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
// Prefer the freshest refresh token on disk over the caller's snapshot when
|
|
1226
|
-
// it's for this client — a concurrent rotation may have consumed the
|
|
1227
|
-
// snapshot. Otherwise (different client / no cache) use the supplied token.
|
|
1228
|
-
const refreshToken =
|
|
1229
|
-
cached && cachedIsForClient(cached, config)
|
|
1230
|
-
? cached.refreshToken
|
|
1231
|
-
: currentRefreshToken;
|
|
1232
|
-
const sessionFingerprint = cached && cachedIsForClient(cached, config)
|
|
1233
|
-
? accessTokenFingerprint(cached.accessToken)
|
|
1234
|
-
: currentAccessToken
|
|
1235
|
-
? accessTokenFingerprint(currentAccessToken)
|
|
1236
|
-
: undefined;
|
|
1237
|
-
|
|
1238
|
-
const body = new URLSearchParams({
|
|
1239
|
-
grant_type: "refresh_token",
|
|
1240
|
-
client_id: config.clientId,
|
|
1241
|
-
refresh_token: refreshToken,
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
let lastFailure: CognitoRefreshError | undefined;
|
|
1245
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1246
|
-
let res: Response;
|
|
1247
|
-
try {
|
|
1248
|
-
res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
|
|
1249
|
-
method: "POST",
|
|
1250
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1251
|
-
body: body.toString(),
|
|
1252
|
-
});
|
|
1253
|
-
} catch (err) {
|
|
1254
|
-
lastFailure = new CognitoRefreshError(
|
|
1255
|
-
`Cognito refresh request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1256
|
-
false,
|
|
1257
|
-
undefined,
|
|
1258
|
-
sessionFingerprint,
|
|
1259
|
-
);
|
|
1260
|
-
if (attempt === 0) continue;
|
|
1261
|
-
throw lastFailure;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
if (!res.ok) {
|
|
1265
|
-
const text = await res.text();
|
|
1266
|
-
const retryable =
|
|
1267
|
-
res.status === 401 ||
|
|
1268
|
-
res.status === 408 ||
|
|
1269
|
-
res.status === 429 ||
|
|
1270
|
-
res.status >= 500;
|
|
1271
|
-
const requiresReauth =
|
|
1272
|
-
res.status === 401 ||
|
|
1273
|
-
(res.status >= 400 &&
|
|
1274
|
-
res.status < 500 &&
|
|
1275
|
-
![408, 429].includes(res.status));
|
|
1276
|
-
lastFailure = new CognitoRefreshError(
|
|
1277
|
-
requiresReauth
|
|
1278
|
-
? "Your HQ session needs a quick refresh. Sign in again to keep sync moving."
|
|
1279
|
-
: `Cognito refresh failed (${res.status}): ${text}`,
|
|
1280
|
-
requiresReauth,
|
|
1281
|
-
res.status,
|
|
1282
|
-
sessionFingerprint,
|
|
1283
|
-
);
|
|
1284
|
-
if (retryable && attempt === 0) continue;
|
|
1285
|
-
throw lastFailure;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
const data = (await res.json()) as CognitoTokenResponse;
|
|
1289
|
-
const tokens: CognitoTokens = {
|
|
1290
|
-
accessToken: data.access_token,
|
|
1291
|
-
idToken: data.id_token,
|
|
1292
|
-
// Persist a rotated refresh token if the endpoint returns one (single-use
|
|
1293
|
-
// clients); otherwise keep the freshest token we sent. This is for cache
|
|
1294
|
-
// correctness, not longevity; rotation does not extend session lifetime.
|
|
1295
|
-
refreshToken: data.refresh_token ?? refreshToken,
|
|
1296
|
-
expiresAt: Date.now() + data.expires_in * 1000,
|
|
1297
|
-
tokenType: "Bearer",
|
|
1298
|
-
};
|
|
1299
|
-
saveCachedTokens(tokens);
|
|
1300
|
-
return tokens;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
throw (
|
|
1304
|
-
lastFailure ??
|
|
1305
|
-
new CognitoRefreshError("Cognito refresh failed after retry", false)
|
|
1306
|
-
);
|
|
1307
|
-
};
|
|
1308
|
-
|
|
1309
|
-
try {
|
|
1310
|
-
return await withRefreshLock(doRefresh);
|
|
1311
|
-
} catch (err) {
|
|
1312
|
-
if (!(err instanceof RefreshLockTimeoutError)) throw err;
|
|
1313
|
-
// A live holder never released within the window (pathological — e.g. a
|
|
1314
|
-
// hung network). Prefer whatever it may have persisted for this client; do
|
|
1315
|
-
// NOT refresh unlocked (that reintroduces the rotation race). If nothing
|
|
1316
|
-
// usable is cached, surface the failure so the caller retries next cycle.
|
|
1317
|
-
const cached = loadCachedTokens();
|
|
1318
|
-
if (cached && cachedIsForClient(cached, config) && !isExpiring(cached)) {
|
|
1319
|
-
return cached;
|
|
1320
|
-
}
|
|
1321
|
-
throw new CognitoRefreshError(
|
|
1322
|
-
"Token refresh is held by another process and no fresh cached session is available; retry shortly.",
|
|
1323
|
-
false,
|
|
1324
|
-
);
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
/**
|
|
1329
|
-
* High-level helper: return a non-expired access token, refreshing or
|
|
1330
|
-
* launching browser login as needed.
|
|
1331
|
-
*
|
|
1332
|
-
* Pass `interactive: false` from automated contexts where you would rather
|
|
1333
|
-
* fail fast than open a browser.
|
|
1334
|
-
*/
|
|
1335
|
-
export async function getValidAccessToken(
|
|
1336
|
-
config: CognitoAuthConfig,
|
|
1337
|
-
options: { interactive?: boolean } = {},
|
|
1338
|
-
): Promise<string> {
|
|
1339
|
-
const interactive = options.interactive ?? true;
|
|
1340
|
-
|
|
1341
|
-
// Machine identities (company agents) never refresh or open a browser —
|
|
1342
|
-
// they re-mint via USER_PASSWORD_AUTH on demand.
|
|
1343
|
-
// Machine identities return the ID token: every hq-cloud caller of this
|
|
1344
|
-
// getter talks to the VAULT API, whose authorizer accepts ID tokens — and
|
|
1345
|
-
// the agent's identity claims (custom:entityType/entityUid) ride the ID
|
|
1346
|
-
// token ONLY. Returning the access token made the first post-cutover
|
|
1347
|
-
// agent box (Petyr, 2026-06-12) resolve as NOBODY: /membership/me -> []
|
|
1348
|
-
// -> 'setup-needed' forever, while the identical call with the ID token
|
|
1349
|
-
// returned its membership. (Consumers needing token_use=access — e.g.
|
|
1350
|
-
// the deploy API — pick tokens per call in hq-cli, not here.)
|
|
1351
|
-
if (isMachineIdentity()) {
|
|
1352
|
-
const machine = await getValidMachineTokens(config);
|
|
1353
|
-
return machine.idToken;
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// Past this point we are on the HUMAN path (cache -> refresh -> browser).
|
|
1357
|
-
// An unattended runner that declared itself a machine must fail here with a
|
|
1358
|
-
// diagnosable error rather than silently trying to sign a person in.
|
|
1359
|
-
assertMachineIdentityWhenRequired();
|
|
1360
|
-
|
|
1361
|
-
let cached = loadCachedTokens();
|
|
1362
|
-
|
|
1363
|
-
// Stale-pool detection: if the cached access token was issued by a
|
|
1364
|
-
// different Cognito App Client than the one we're talking to now, drop the
|
|
1365
|
-
// cache and re-authenticate. Without this, a user holding a pre-cutover
|
|
1366
|
-
// dev-pool token would either keep using a token the prod API rejects
|
|
1367
|
-
// with 401, or attempt a refresh against the prod token endpoint with a
|
|
1368
|
-
// dev refresh token (InvalidGrant). See `decodeAccessTokenClientId` for
|
|
1369
|
-
// the full rationale.
|
|
1370
|
-
if (cached) {
|
|
1371
|
-
const cachedClientId = decodeAccessTokenClientId(cached.accessToken);
|
|
1372
|
-
if (cachedClientId !== null && cachedClientId !== config.clientId) {
|
|
1373
|
-
invalidateCachedTokensByFingerprint(
|
|
1374
|
-
accessTokenFingerprint(cached.accessToken),
|
|
1375
|
-
);
|
|
1376
|
-
cached = null;
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
if (cached && !isExpiring(cached)) return cached.accessToken;
|
|
1381
|
-
|
|
1382
|
-
if (cached) {
|
|
1383
|
-
try {
|
|
1384
|
-
const refreshed = await refreshTokens(
|
|
1385
|
-
config,
|
|
1386
|
-
cached.refreshToken,
|
|
1387
|
-
cached.accessToken,
|
|
1388
|
-
);
|
|
1389
|
-
return refreshed.accessToken;
|
|
1390
|
-
} catch (err) {
|
|
1391
|
-
if (err instanceof CognitoRefreshError && err.requiresReauth) {
|
|
1392
|
-
invalidateCachedTokensByFingerprint(
|
|
1393
|
-
err.sessionFingerprint ?? accessTokenFingerprint(cached.accessToken),
|
|
1394
|
-
);
|
|
1395
|
-
}
|
|
1396
|
-
if (!interactive) throw err;
|
|
1397
|
-
// fall through to interactive login
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
if (!interactive) {
|
|
1402
|
-
throw new CognitoAuthError(
|
|
1403
|
-
"No valid HQ session and interactive login is disabled. Run `hq login` first.",
|
|
1404
|
-
);
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
const fresh = await browserLogin(config);
|
|
1408
|
-
return fresh.accessToken;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
// ---------------------------------------------------------------------------
|
|
1412
|
-
// Helpers
|
|
1413
|
-
// ---------------------------------------------------------------------------
|
|
1414
|
-
|
|
1415
|
-
function escapeHtml(s: string): string {
|
|
1416
|
-
return s
|
|
1417
|
-
.replace(/&/g, "&")
|
|
1418
|
-
.replace(/</g, "<")
|
|
1419
|
-
.replace(/>/g, ">")
|
|
1420
|
-
.replace(/"/g, """)
|
|
1421
|
-
.replace(/'/g, "'");
|
|
1422
|
-
}
|