/** * Cognito browser-OAuth helper (VLT-9). * * Drives the Cognito Hosted UI authorization-code + PKCE flow for the * vault-service User Pool. Used by the CLI (`hq login`, `create-hq`) to * obtain a JWT that is then passed to the vault-service API as * `Authorization: Bearer `. * * Why PKCE: the CLI is a public client (no secret), so we use PKCE per * RFC 7636 to prove that the same process that started the auth request * is the one exchanging the code for tokens. * * Why a localhost callback: Cognito allows `http://localhost:*` as a * redirect URI specifically for native/CLI apps (RFC 8252 §7). We spin * up a one-shot HTTP server on the chosen port, capture exactly one * callback, then close it. */ export interface CognitoAuthConfig { /** AWS region the User Pool lives in (e.g. "us-east-1"). */ region: string; /** Cognito User Pool Domain prefix (e.g. "vault-indigo-hq-dev"). */ userPoolDomain: string; /** App Client ID (e.g. "7r7an9keh0u6hlsvepl74tvqb0"). */ clientId: string; /** Loopback callback port. Defaults to 3000. */ port?: number; /** OAuth scopes. Defaults to ["openid", "email", "profile"]. */ scopes?: string[]; /** * Force a federated IdP (e.g. "Google"). When set, the Hosted UI IdP picker * is bypassed and Cognito redirects straight to the provider. When omitted, * Cognito shows its default picker. */ identityProvider?: string; /** * OAuth `prompt` param (e.g. "select_account"). Only meaningful when the IdP * honors it — Google uses it to force account re-selection. */ prompt?: string; } export interface CognitoTokens { accessToken: string; idToken: string; refreshToken: string; /** 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. */ expiresAt: string | number; tokenType: "Bearer"; } /** Returned when an interactive login is needed but stdin/browser is unavailable. */ export declare class CognitoAuthError extends Error { constructor(message: string); } /** * A failed refresh attempt with enough classification for callers to decide * whether the cached session is still worth preserving. Network/5xx/429 * failures remain recoverable; an invalid/expired refresh token means the * stored session can no longer make progress and should be cleared. */ export declare class CognitoRefreshError extends CognitoAuthError { readonly requiresReauth: boolean; readonly statusCode?: number; readonly sessionFingerprint?: string; constructor(message: string, requiresReauth: boolean, statusCode?: number, sessionFingerprint?: string); } /** * Is `err` a Cognito identity refusal that the caller must treat as a run-level * terminal auth condition (clear the stale session, surface one reauth prompt)? * * Two cases, exhaustive with {@link isRetryableIdentityRefusal} over the Cognito * error classes: * 1. A {@link CognitoRefreshError} whose refresh HTTP status was classified as * requiring reauthentication (401, or a 4xx that is not 408/429) — the * `requiresReauth` bit computed once from the status in the refresh path * above. The message text is never consulted. * 2. Any other {@link CognitoAuthError} (e.g. an interactive login was needed * but stdin/browser was unavailable). These are never recoverable by a * silent retry. * * A long-running sync resolves its bearer through a getter, so this refusal can * surface mid-pass — long after the up-front auth boundary — from any vault or * S3 request. It is a single session-level fact, not a per-file/per-company * transfer error, so recognizing it here lets every catch site stop the fanout * once instead of fanning one refusal out into hundreds of per-item errors. */ export declare function isTerminalIdentityRefusal(err: unknown): boolean; /** * Is `err` a Cognito identity refusal that is still retryable — a * {@link CognitoRefreshError} whose status was transient (408/429/5xx) or whose * refresh request failed at the transport layer, after the single built-in * retry was already spent (`requiresReauth === false`)? * * Callers treat this like a transient network failure: keep the cached session, * do NOT reauthenticate, and exit with the transient-retry code so the next * poll can try again. Reads the authoritative `requiresReauth` bit only. */ export declare function isRetryableIdentityRefusal(err: unknown): boolean; /** * Root for hq CLI state: the token cache and its refresh lock. * * `HQ_STATE_DIR` overrides it, mirroring the existing `HQ_MACHINE_CREDS_FILE` * override for the creds path. Before this existed the location was * `os.homedir()/.hq` with NO escape hatch, which made every auth path — machine * identity included — require a writable HOME. A child job running with a * read-only HQ state directory therefore died on a *cache write* before it ever * reached the network: * * EACCES: permission denied, open '…/.hq/cognito-tokens.json.lock.candidate.…' * * Pointing HQ_STATE_DIR at a writable tmpfs lets such a job authenticate while * leaving the credentials themselves wherever they are. * * Resolved per call (not once at import) so a caller can set it during startup * and so tests can exercise both branches. */ export declare function hqStateDir(): string; /** * The PERSON Cognito token cache file inside {@link hqStateDir}. * * This is the file the desktop app reads to decide who is signed in. It must * only ever hold a person's session. Machine identities have their own cache * ({@link machineTokenCacheFile}); nothing in this module writes a machine * token here, whatever the caller did or did not set up first. */ export declare function tokenCacheFile(): string; /** * Where a machine identity keeps its minted sessions. * * `HQ_MACHINE_TOKEN_STATE_DIR` wins (hq-cli sets it per local bot). Otherwise * the mesh daemon state dir — `$HQ_WORK_MESH_ROOT/daemon`, default * `~/.hq/work-mesh/daemon` — which is where hq-cli has always redirected * machine mints on agent boxes, so an upgraded box keeps its existing cache. * Either way it is never {@link tokenCacheFile}, the person's sign-in. * * Incident (2026-09-17): a local setup bot on a customer's Mac ran with its * creds in scope, the mint saved to `tokenCacheFile()`, and the desktop app — * which trusts that file — showed the bot as the signed-in user. Every write of * a machine token to the person file is a sign-in as the wrong principal on * that machine; the location is therefore chosen by WHO the token is for, not * by which caller happened to redirect `HQ_STATE_DIR` first. */ export declare function machineTokenStateDir(): string; /** The Cognito token cache file inside {@link machineTokenStateDir}. */ export declare function machineTokenCacheFile(): string; /** Stable, non-secret identity for one access-token generation. */ export declare function accessTokenFingerprint(accessToken: string): string; /** * Mark only the rejected token generation unusable. Automatic auth failures * must not unlink the shared cache: another process may have completed a * refresh or login after the failing request started. * * The marker is written beside the person file and, when this process holds * machine creds, beside the machine file too. A fingerprint names exactly one * token generation, so a marker in the cache that never held it is inert. */ export declare function invalidateCachedTokensByFingerprint(fingerprint: string | undefined): void; /** * True when `tokens` were minted for a machine identity (agent or outpost), * judged by the ID token's `custom:entityType` claim. People never carry that * claim, so its presence is decisive; a token that will not decode is not * treated as a machine's. */ export declare function tokensCarryMachineIdentity(tokens: CognitoTokens): boolean; /** * The PERSON's cached session, or null. * * Only ever yields a person. A machine token found in the person file was * written there by an older library; serving it would sign this process — and * the desktop app that shares the file — in as that machine. It is refused, and * the human path re-authenticates. */ export declare function loadCachedTokens(): CognitoTokens | null; /** The MACHINE identity's cached session, or null. */ export declare function loadCachedMachineTokens(): CognitoTokens | null; /** * Persist a session to the cache that belongs to the principal it names. * * The destination is decided by the token's own ID claims, not by the caller: * a machine token goes to the machine cache and a person token to the person * cache, whatever `HQ_STATE_DIR` or the creds-file situation is at the time. * This is the one rule that makes the incident impossible rather than merely * unlikely. */ export declare function saveCachedTokens(tokens: CognitoTokens): void; /** Sign the PERSON out of this machine. Leaves any machine identity's cache alone. */ export declare function clearCachedTokens(): void; /** Drop the MACHINE identity's cached session. Leaves the person signed in. */ export declare function clearCachedMachineTokens(): void; /** Thrown when the refresh lock can't be acquired within the deadline. */ export declare class RefreshLockTimeoutError extends Error { constructor(); } /** True when the token expires within the given buffer (default 60s). */ export declare function isExpiring(tokens: CognitoTokens, bufferSeconds?: number): boolean; /** * Decode the `client_id` claim from a Cognito access token (no signature * verification — we only need to identify which App Client minted it). * Returns null when the token can't be parsed. * * Used by `getValidAccessToken` to detect stale cached sessions that target * a different Cognito App Client. The canonical case is a pre-2026-04-25 * cache file holding a `hq-vault-dev` token after the user upgraded to a * post-cutover CLI: the access token stays "non-expiring" for an hour but * the prod vault API rejects it with 401, and the dev refresh token can't * be exchanged at the prod token endpoint. Detecting the mismatch and * forcing a re-login is the only safe self-heal. */ export declare function decodeAccessTokenClientId(accessToken: string): string | null; export interface MachineCreds { /** Cognito username for the machine user. */ username: string; /** Long-lived machine secret (USER_PASSWORD_AUTH password). */ secret: string; /** App client to mint against — provisioned creds carry their own * (USER_PASSWORD_AUTH must be enabled on it); falls back to config. */ clientId?: string; /** Cognito region for the mint endpoint; falls back to config. */ region?: string; /** Expected machine kind carried in the ID token's custom:entityType claim. */ entityType?: "agent" | "outpost"; /** Exact expected machine uid carried in custom:entityUid. */ entityUid?: string; /** * Where the machine runs. Absent means `hosted` (an HQ-provisioned EC2 box * that mints straight against Cognito). `external` is a bot on hardware HQ * does not own: the secret alone is not enough — every mint must also prove * possession of the host's Ed25519 key (see {@link mintExternalMachineTokens}). */ runtime?: MachineRuntime; /** Path to the Ed25519 private key (PKCS8 PEM) an external agent signs with. */ hostKeyPath?: string; /** hq-pro control-plane origin an external agent mints through. */ apiBaseUrl?: string; } export type MachineRuntime = "hosted" | "external"; /** Default hq-pro control plane (HQ_VAULT_API_URL overrides). */ export declare const DEFAULT_MACHINE_API_BASE_URL = "https://hqapi.hq.computer"; /** Resolve the machine-creds file path (HQ_MACHINE_CREDS_FILE overrides). */ export declare function machineCredsFilePath(): string; /** * Load machine credentials, or null when this process is not running as a * machine identity (no creds file / unreadable / malformed). */ export declare function loadMachineCreds(): MachineCreds | null; /** True when the creds describe an external (host-key signed) agent. */ export declare function isExternalMachineCreds(creds: MachineCreds): boolean; /** * The message an external agent signs to prove it holds the host key: * `${agentUid}.${nonce}` (api-contract v1, "Machine token mint"). */ export declare function externalMintChallengeMessage(agentUid: string, nonce: string): string; /** * Sign an external mint challenge with the host's Ed25519 private key. * Returns the base64 signature the server verifies against the public key it * stored at enrollment. Ed25519 is a pure-EdDSA scheme, so no digest is * passed (Node requires `null` for that key type). */ export declare function signExternalMintChallenge(privateKeyPem: string | Buffer, agentUid: string, nonce: string): string; /** hq-pro origin an external agent mints through (creds → env → default). */ export declare function externalMintApiBaseUrl(creds: MachineCreds): string; /** True when machine credentials are present — the CLI is a machine identity. */ export declare function isMachineIdentity(): boolean; /** * Whether the caller declared this process MUST run as a machine identity * (`HQ_REQUIRE_MACHINE_IDENTITY=1`). * * Set it on unattended runners — child jobs, workers, skill subprocesses. It * converts the worst failure mode in this module into a clear one. * * Without it, a machine context that cannot find its credentials does not * error: `isMachineIdentity()` returns false and the caller quietly proceeds to * the HUMAN Cognito path. On a headless box that means "launching browser * sign-in" and a hang until something times out — observed in production, and * reported by a client as child jobs "falling back to refreshing a human HQ * session". The cause (a different HOME, so a different creds path) is nowhere * in that message. */ export declare function machineIdentityRequired(): boolean; /** * Throw a typed, diagnosable error when machine identity is REQUIRED but the * creds file is not readable. Names the exact path checked and how it was * derived, so the fix (set `HQ_MACHINE_CREDS_FILE`, or restore HOME) is * readable straight off the error. No-op when not required or already a * machine identity. */ export declare function assertMachineIdentityWhenRequired(): void; /** Tunables for the machine-mint retry-on-throttle loop. Defaults are the * production values; tests pass `baseDelayMs: 0` to retry without waiting. */ export interface MintRetryOptions { /** Retries AFTER the first attempt (so total attempts = maxRetries + 1). */ maxRetries?: number; /** Backoff for the first retry in ms; doubles each subsequent attempt. */ baseDelayMs?: number; /** Ceiling for any single backoff sleep. */ maxDelayMs?: number; } /** * Mint and persist a fresh machine session while holding the process-shared * token lock. The unlocked implementation is private so public callers cannot * race another process's cache write. */ export declare function mintMachineTokens(config: CognitoAuthConfig, creds?: MachineCreds, retry?: MintRetryOptions): Promise; /** * Return a valid (non-expiring) machine session, re-minting on demand. * Cache-hit path never touches the network — and after the first mint, the * in-process memo serves every subsequent request for the life of the token. */ export declare function getValidMachineTokens(config: CognitoAuthConfig): Promise; /** * Open the Cognito Hosted UI in the user's browser, wait for the redirect * back to localhost, and exchange the auth code for tokens. * * Times out after 5 minutes if the user doesn't complete the flow. */ export declare function browserLogin(config: CognitoAuthConfig): Promise; /** * Use the refresh token to obtain a fresh access token without user interaction. * * Concurrency-safe. Several hq processes on a box refresh the same cached * session at once, so the whole read→refresh→save cycle runs under a * cross-process lock, and inside the lock we (a) reuse a token another refresher * just produced if it's still fresh, and (b) otherwise refresh with the FRESHEST * token on disk rather than the caller's snapshot. This keeps the shared cache * consistent, and stays correct if the app client uses single-use (rotated) * tokens — a stale snapshot would otherwise be an already-consumed token * (`invalid_grant`). NOTE: rotation is NOT how boxes stay signed in — a rotated * token inherits the original's remaining lifetime, so it does not extend the * session (AWS). Durable never-log-out is per-box machine identity (ADR-0009). */ export declare function refreshTokens(config: CognitoAuthConfig, currentRefreshToken: string, currentAccessToken?: string): Promise; /** * High-level helper: return a non-expired access token, refreshing or * launching browser login as needed. * * Pass `interactive: false` from automated contexts where you would rather * fail fast than open a browser. */ export declare function getValidAccessToken(config: CognitoAuthConfig, options?: { interactive?: boolean; }): Promise; //# sourceMappingURL=cognito-auth.d.ts.map