import { Interceptor, ChannelCredentials, Client } from '@grpc/grpc-js'; import { defaultProvider } from '@aws-sdk/credential-provider-node'; import { KMSClient } from '@aws-sdk/client-kms'; import { JWK } from 'jose'; type GetTokenParams = { audience: string; scopes?: string[]; instanceId?: string; }; /** Issued token shape produced by a pluggable {@link TokenIssuer}. */ type IssuedToken = { /** Bearer token string. */ token: string; /** Absolute expiry in epoch seconds. */ expiresAt: number; }; /** * Pluggable token-issuer protocol. Real impl talks SigV4 to API Gateway * (the default `refreshToken` path); tests inject a stub to exercise the * cache + TTL refresh behavior deterministically (parity with the Python * + Go ports which take the issuer as a constructor arg). */ interface TokenIssuer { /** * `opts.signal` is aborted when the manager's mint bound * (`mintTimeoutMs`) expires. Honour it to release the request; the manager * stops waiting at the bound either way. */ issue(audience: string, scopes: readonly string[] | undefined, instanceId: string | undefined, opts?: { signal?: AbortSignal; }): Promise; } /** * Thrown by the {@link S2STokenManager} constructor when the supplied * options are mutually inconsistent. Typed so SDK wrappers can * `instanceof`-discriminate config errors the same way they * discriminate verifier errors via {@link S2SVerifyError}. */ declare class S2STokenManagerConfigError extends Error { readonly name = "S2STokenManagerConfigError"; } /** Common fields shared by both options variants. */ type S2STokenManagerOptionsBase = { /** Optional: override credentials (otherwise uses defaultProvider chain) */ credentialsProvider?: ReturnType; /** Refresh when token has <= this many seconds remaining. Default 30. */ refreshSkewSeconds?: number; /** Optional: additional headers (e.g. tracing) */ extraHeaders?: Record; /** * Optional clock injection (epoch milliseconds). Defaults to `Date.now`. * Used by tests to advance time deterministically across the refresh * boundary; parity with the Python + Go ports' `now_fn` / `NowFn`. */ nowFn?: () => number; /** * Optional jitter source in `[0, 1)`. Each cached entry refreshes up to * 10% of its lifetime EARLIER than `refreshSkewSeconds` alone would, scaled * by this value. Defaults to `Math.random`; tests inject `() => 0`. */ jitterFn?: () => number; /** Upper bound on cached entries (oldest evicted first). Default 256. */ maxEntries?: number; /** * How long ONE mint may take, in real milliseconds, before it fails as a * TRANSIENT {@link S2STokenMintError} (issue 816). Default 1500. It covers * the whole mint: signing, the request, and reading the response. Zero, * negative or non-finite means the default — the bound cannot be switched * off. Values above 9000 are clamped to 9000 with a warning, so a mint * always settles before the 10-second single-flight join window, after * which a later caller would stop joining it. * * On the SigV4 path the constructor starts the AWS credential lookup at * once (errors swallowed), so a cold lookup — instance metadata, STS — is * paid before the first mint instead of inside its bound. The default * provider memoizes; a custom `credentialsProvider` that does not is * called once more than before (once to warm, once per mint). */ mintTimeoutMs?: number; /** * Called when a BACKGROUND refresh fails (the caller was already served a * valid cached token, so nothing throws). Defaults to a `console.warn` that * never includes the token. `denied` is true for an issuer denial, which * also evicts the entry. */ onRefreshError?: (info: { audience: string; denied: boolean; error: unknown; }) => void; }; /** * Discriminated union — at compile time, callers must either supply * `tokenIssuer` OR `apiBaseUrl + region`; omitting both is a type * error. Preserves the DX guarantee the SigV4-only path had before * the test-injection seam was added. */ type S2STokenManagerOptions = S2STokenManagerOptionsBase & ({ /** Pluggable issuer; when set, the manager bypasses SigV4 entirely. */ tokenIssuer: TokenIssuer; apiBaseUrl?: string; region?: string; } | { tokenIssuer?: never; /** Full URL to your API Gateway stage base. */ apiBaseUrl: string; /** Region for API Gateway. */ region: string; }); /** * A token mint failed at the issuer. `transient` separates "try again" * (network, HTTP 5xx, 408, 429) from a DENIAL (HTTP 401 / 403 and other 4xx): * a denial evicts the cached entry and is never retried against a stale token. * A mint that exceeds `mintTimeoutMs` is transient with no `status`; its * `cause` is a `TimeoutError` `DOMException`. The `S2STokenMintError` itself * is the abort reason handed to the issuer's signal. */ declare class S2STokenMintError extends Error { readonly name = "S2STokenMintError"; /** HTTP status from the issuer, when there was a response. */ readonly status?: number; readonly transient: boolean; constructor(message: string, opts: { status?: number; transient: boolean; cause?: unknown; }); } declare class S2STokenManager { private readonly apiBaseUrl; private readonly region; private readonly credsProvider; private readonly extraHeaders?; private readonly tokenIssuer?; private readonly refreshSkewMs; private readonly nowFn; private readonly jitterFn; private readonly maxEntries; private readonly mintTimeoutMs; private readonly onRefreshError; /** Per-instance, in-memory only. Insertion order = eviction order. */ private readonly cache; /** * Mints in flight, one per key (single-flight) — cold mints AND background * refreshes, so a caller whose token expires mid-refresh joins that refresh * instead of starting a second mint for the same key. */ private readonly inflight; /** * "The issuer's LATEST word wins." Every mint takes the next sequence number * when it STARTS. Per key we remember the highest sequence whose answer has * been applied (a stored token, a denial, or a `clear()`); a mint may only * change the cache when its own sequence is higher. So a slow mint — good OR * denied — that settles after a newer one already answered changes nothing: * a late token cannot come back, and a late denial cannot evict the newer * token or disturb the newer mint still in flight. */ private mintSeq; private readonly settledSeq; /** Every sequence at or below this is stale for EVERY key (bare `clear()`). */ private seqFloor; private warnedShortLifetime; constructor(opts: S2STokenManagerOptions); /** * Get a valid bearer token, from cache when possible (hub decision D845). * * - fresh hit → cached token, no I/O. * - inside refresh window → cached token AT ONCE + one background refresh. * - cold or expired → await ONE shared mint for this key. */ getToken(params: GetTokenParams): Promise; /** Start ONE mint for `key` and register it so later callers can join it. */ private startMint; /** * Drop cached token(s): everything when called bare, otherwise every entry * matching the given `audience` / `scopes` / `instanceId`. A mint already in * flight still answers the callers ALREADY waiting on it, but it is detached: * it does not repopulate a cleared key, and a caller arriving AFTER clear() * starts a fresh mint instead of joining it. (The case that matters: clear() * on a key rotation must never hand out a pre-rotation token afterwards.) */ clear(params?: Partial): void; /** * Drop `key` and make every mint started so far for it unable to change the * cache. Waiters already joined to such a mint are still answered. */ private invalidateKey; /** * The issuer DENIED mint `seq` for `key`. Returns whether the denial was * applied. It is ignored when a NEWER mint has already answered — the issuer * said yes after it said no. When applied it evicts the cached token, and any * OLDER mint still in flight can no longer store; a NEWER mint still in * flight is left alone, registered and free to store, because its answer * will be the later one. */ private applyDenial; private settled; private markSettled; /** * Cache a freshly minted token — unless `clear()`, a denial, or another * token from a mint that started LATER has already settled this key. */ private store; private isRefreshing; /** * One background refresh per entry; never throws into the caller. It is * registered in `inflight`, so a caller whose token expires while the refresh * is still running JOINS it rather than starting a second mint. */ private refreshInBackground; /** * Mint one token, BOUNDED by `mintTimeoutMs` (issue 816). The issuer is raced * against the manager's own timer, so the bound holds even when the issuer * ignores the abort; the abort still releases a cooperating issuer's * request. On timeout the mint fails as a TRANSIENT {@link S2STokenMintError} * and whatever the issuer returns later is discarded — it never reaches the * cache, because the store happens in the caller of this method, only on * success. */ private mint; /** * One mint at the issuer, unbounded — {@link mint} bounds it. No caching * here; `getToken` owns that. Kept as one method so the two mint paths — * injected issuer vs SigV4/API-Gateway — read clearly and stay * parity-aligned with the Python + Go ports. */ private mintOnce; private signRequest; } /** * gRPC client interceptor: acquires an S2S token from the configured * `S2STokenManager` and attaches it to the outgoing `authorization` * metadata header as `Bearer `. * * Lifted 1:1 from provisioning's * `server/grpc/interceptors/auth/client/auth-client-interceptor.ts`. */ type ClientAuthOptions = { tokenManager: S2STokenManager; audience: string; scopes?: string[]; instanceId?: string; }; /** * Creates a gRPC client interceptor that acquires an S2S token and adds * it to the metadata. * * Usage: * * ```ts * const client = new MyGrpcServiceClient(target, credentials, { * interceptors: [ * createClientAuthInterceptor({ * tokenManager, * audience: "nucleus-internal", * scopes: ["flows:write"], * instanceId: process.env.INSTANCE_ID, * }), * ], * }); * ``` */ declare function createClientAuthInterceptor(opts: ClientAuthOptions): Interceptor; /** * Shared S2S gRPC client construction + cold-channel readiness helpers. * * WHY THIS EXISTS (TIER-3B cold-channel first-call resilience): * The FIRST RPC on a freshly-created (cold) gRPC client channel can * transiently fail — under Bun especially — with * `13 INTERNAL: lookup failed`. This is the grpc-js/Bun cold-channel * trap: at construction the channel is in state IDLE and has done NO name * resolution or TCP connect yet. The first call races the resolver's * first DNS lookup + connect; if the call reaches the transport before the * subchannel is READY the resolver can surface a transient `lookup failed` * that a RETRY (once warm) does not reproduce. In a freshly-DEPLOYED * service this 500s the very first cross-service request after boot. * * grpc-js gives us a clean primitive to avoid the race: `waitForReady`, * which blocks until the channel connects (or the deadline elapses), * forcing name resolution + the initial connect BEFORE the first RPC. * But services build their S2S clients ad-hoc — * `new XClient(target, credentials.createInsecure(), { interceptors: […] })` * — with no shared factory and no `waitForReady`, so every service * re-implements (or, today, omits) the warm-up. This module gives them a * single, composable place to (a) await readiness on an existing client * and (b) build an auth-wired S2S client that optionally warms itself. * * NON-BREAKING: this is an ADDITION. `createClientAuthInterceptor` and all * existing `new XClient(...)` call-sites keep working untouched. Adopting * `waitForClientReady` / `buildS2SClient` is opt-in. */ /** * The structural subset of a `@grpc/grpc-js` `Client` this module needs. * Every generated `*Client` (which extends `grpc.Client`) is assignable to * it, so callers never have to widen a concrete client type. */ interface GrpcClientLike { waitForReady(deadline: number | Date, callback: (error?: Error) => void): void; } /** * A generated-client constructor shape: * `new (address, credentials, options?) => T`. Every protoc/ts-proto grpc * client class matches this. */ type GrpcClientCtor = new (address: string, channelCredentials: ChannelCredentials, options?: { interceptors?: Interceptor[]; [key: string]: unknown; }) => T; /** Default readiness deadline: enough to cover a cold DNS lookup + connect. */ declare const DEFAULT_WAIT_FOR_READY_MS = 5000; /** * Promise wrapper around `client.waitForReady(deadline, cb)`. * * Resolves once the channel reaches READY (name resolution + initial connect * done — so the NEXT RPC is warm and does not race the resolver). Rejects if * the channel is not ready within `deadlineMs`. Use this once, right after * constructing a client (typically at service boot), so a freshly-deployed * service's first peer call does not 500 on the grpc-js/Bun cold-channel * `13 INTERNAL: lookup failed` race. * * @param client Any grpc-js client (or generated subclass). * @param deadlineMs Milliseconds from now to wait. Defaults to * {@link DEFAULT_WAIT_FOR_READY_MS}. */ declare function waitForClientReady(client: GrpcClientLike, deadlineMs?: number): Promise; /** Options for {@link buildS2SClient}. */ interface BuildS2SClientOptions extends ClientAuthOptions { /** * Channel credentials. Defaults to `credentials.createInsecure()` — the * S2S plaintext-over-SG default (mTLS graduation is a separate concern; * pass explicit `createSsl(...)` creds to opt in). The auth interceptor * carries the bearer token regardless of channel security. */ channelCredentials?: ChannelCredentials; /** * Extra client interceptors to compose AFTER the auth interceptor (auth * runs first so downstream interceptors see the `authorization` metadata). */ extraInterceptors?: Interceptor[]; /** * Extra channel options merged into the client options (e.g. * `"grpc.keepalive_time_ms"`). `interceptors` here is ignored — use * `extraInterceptors`. */ channelOptions?: Record; /** * When set (> 0), `buildS2SClient` is `async` and AWAITS * {@link waitForClientReady} with this deadline before returning, so the * returned client is already warm (cold-channel first-call resilience). A * readiness failure REJECTS the returned promise. When 0 / undefined the * client is returned synchronously WITHOUT warming (caller may warm later). */ waitForReadyMs?: number; } /** * Build an S2S gRPC client wired with the `@nodii/grpc-auth` client auth * interceptor (bearer-token acquisition + `x-user-context` forwarding) on an * insecure-by-default channel, and — when `waitForReadyMs` is set — warm the * cold channel before returning so the first peer call does not race the * resolver. * * Two overloads: * - WITHOUT `waitForReadyMs` (or `0`): returns the client SYNCHRONOUSLY. * - WITH a positive `waitForReadyMs`: returns a `Promise` that resolves * once the channel is READY (or rejects on the readiness deadline). * * @example * ```ts * // Warmed at boot — first call is cold-channel-safe. * const client = await buildS2SClient(BillingClient, target, { * tokenManager, audience: "nodii-billing", scopes: ["invoices:read"], * waitForReadyMs: 5000, * }); * ``` */ declare function buildS2SClient(Ctor: GrpcClientCtor, target: string, options: BuildS2SClientOptions & { waitForReadyMs: number; }): Promise; declare function buildS2SClient(Ctor: GrpcClientCtor, target: string, options: BuildS2SClientOptions): T; /** * Client-side S2S JWT signing helper (drift `81f3f606`, wave item #21). * * Produces an S2S JWT for OUTBOUND gRPC calls and the canonical * `authorization: Bearer ` metadata pair. The full flow: * * claims → KMS Sign (RSASSA_PKCS1_V1_5_SHA_256) → RS256 JWT * → grpc-metadata `authorization: Bearer ` * * The produced JWT verifies against this package's `verifyS2SToken` * (Part A) — sign with the helper, verify with the verifier, claims match. * * ## ONE surface over LocalStack KMS (test) + prod KMS (prod) * * The signer wraps a KMS asymmetric SIGN_VERIFY key behind a single * surface. It deliberately MIRRORS `@nodii/pii`'s AWS-KMS construction * conventions (`AwsKmsClient` / `buildDefaultAwsClients` in * `ts/pii/src/aws.ts`): same `region` + optional `endpoint` override + * optional explicit `credentials`, with the SAME `KMS_ENDPOINT` env-var * fallback and the SAME inert-credentials-when-pointing-at-localstack * behaviour. Production omits `endpoint` → real AWS KMS; tests set * `KMS_ENDPOINT=http://localhost:4566` (or pass `endpoint`) → LocalStack * KMS. No new credential/endpoint convention is invented. * * Why a DISTINCT KMS client and not literal reuse of `pii.AwsKmsClient`: * pii's `KmsClient` interface is envelope-encryption only * (`generateDataKey` / `decrypt`) — it has no `Sign` / `GetPublicKey` * operation, so it physically cannot mint a JWT signature. This helper * adds the `kms.Sign` / `kms.GetPublicKey` surface using the IDENTICAL * construction pattern, rather than forking pii's envelope code. * * Key MANAGEMENT (CreateKey, alias, rotation, IAM) stays caller-owned, the * same way `@nodii/pii` keeps tenant-CMK provisioning out of the lib — the * signer only Signs + reads the public key. */ /** * AWS construction options — mirrors `@nodii/pii`'s `AwsClientOptions` * (region + optional endpoint + optional credentials) verbatim so the * localstack-vs-prod selection behaves identically across both libs. */ interface KmsSignerAwsOptions { region: string; /** * Endpoint override for LocalStack / non-prod AWS-compatible targets * (e.g. `http://localhost:4566`). Omit in prod → real AWS KMS. Falls * back to `process.env.KMS_ENDPOINT` when omitted (parity with * `@nodii/pii`'s `buildDefaultAwsClients`). */ endpoint?: string; /** * Explicit credentials. When omitted AND an endpoint is in play * (localstack), inert `test`/`test` creds are forced — same guard as * `@nodii/pii`, so the default credential chain never proxies real AWS * keys to localstack. In prod (no endpoint) the SDK's default chain is * used. */ credentials?: { accessKeyId: string; secretAccessKey: string; sessionToken?: string; }; } /** Options for {@link S2SJwtSigner}. */ interface S2SJwtSignerOptions { /** * AWS construction options OR a pre-built `@aws-sdk/client-kms` * `KMSClient`. Prefer the options form; pass a client only when you * already manage one (parity with `@nodii/pii` accepting an explicit * `kmsClient`). */ aws?: KmsSignerAwsOptions; /** Pre-built KMS client (overrides `aws`). */ kmsClient?: KMSClient; /** * KMS key id / ARN / alias of the asymmetric RSA SIGN_VERIFY key used * to sign. (Create it with `KeySpec=RSA_2048, KeyUsage=SIGN_VERIFY`.) */ keyId: string; /** * JWT `kid` header written into every minted token. Drives JWKS key * selection on the verifier (D203 — `kid` is mandatory). Defaults to * `opts.keyId` when omitted. */ kid?: string; /** `iss` claim — the S2S issuer URL/id (ENFORCED on verify per D203). */ issuer: string; /** * `svc` claim — the source service's CANONICAL service id (D188: * matches the hub `services` registry id verbatim; e.g. * `nodii-tenant-service`). This is what the verifier surfaces as * `serviceName`. */ serviceName: string; /** * Optional `sid` claim (service instance / id). When omitted the * verifier falls back to `sub` for `serviceId`. */ serviceId?: string; /** Default token lifetime in seconds. Default 300 (5 min). */ defaultTtlSeconds?: number; /** * Clock injection (epoch ms). Defaults to `Date.now`. Tests advance * this to mint deliberately-expired tokens for the sad path. */ nowFn?: () => number; } /** Per-call claims for {@link S2SJwtSigner.sign}. */ interface SignS2SParams { /** `aud` claim (ENFORCED on verify per D203). */ audience: string | string[]; /** `sub` claim. Defaults to `serviceId ?? serviceName`. */ subject?: string; /** Scopes → space-delimited `scope` claim (the verifier parses both shapes). */ scopes?: string[]; /** Optional `instance_id` claim. */ instanceId?: string; /** * Optional `jti` claim. Generated via `crypto.randomUUID()` when * omitted — REQUIRED whenever the verifier runs with a replay store, so * the helper always mints one by default. */ jti?: string; /** Override the signer's default TTL for this token (seconds). */ ttlSeconds?: number; /** Extra custom claims merged into the payload (won't override reserved claims). */ extraClaims?: Record; } /** * Mints RS256 S2S JWTs by signing the JWT signing-input with KMS. One * surface over LocalStack KMS (test) + prod KMS (prod); see module doc. */ declare class S2SJwtSigner { private readonly kms; private readonly keyId; private readonly kid; private readonly issuer; private readonly serviceName; private readonly serviceId?; private readonly defaultTtlSeconds; private readonly nowFn; constructor(opts: S2SJwtSignerOptions); /** * Mint a signed RS256 S2S JWT. Builds the locked claim shape * (`iss`/`aud`/`sub`/`iat`/`exp`/`jti`/`svc` + optional `sid`/`scope`/ * `instance_id`), KMS-signs the `header.payload` signing-input with * `RSASSA_PKCS1_V1_5_SHA_256`, and returns the compact JWT string. */ sign(params: SignS2SParams): Promise; /** * Mint a JWT and return the canonical gRPC metadata pair * `{ authorization: "Bearer " }`, ready to splat onto outbound * `grpc.Metadata` / a call's `metadata` object. */ signAuthorizationMetadata(params: SignS2SParams): Promise<{ authorization: string; }>; /** * Fetch the signing key's PUBLIC half from KMS as a JWK (with `kid` / * `alg: RS256` / `use: sig` set per D203), suitable for assembling a * static JWKS that the verifier (`createJwksProvider({ staticJwks })`) * resolves against. Lets a test round-trip sign → verify with NO * `/.well-known/jwks.json` endpoint. */ exportPublicJwk(): Promise; /** Free the underlying KMS client's sockets. */ destroy(): void; } /** * Convenience constructor — `new S2SJwtSigner(opts)`. Mirrors the * factory ergonomics of `createSigV4S3JWKSet` / `createJwksProvider`. */ declare function createS2SJwtSigner(opts: S2SJwtSignerOptions): S2SJwtSigner; export { type BuildS2SClientOptions, type ClientAuthOptions, DEFAULT_WAIT_FOR_READY_MS, type GetTokenParams, type GrpcClientCtor, type GrpcClientLike, type IssuedToken, type KmsSignerAwsOptions, S2SJwtSigner, type S2SJwtSignerOptions, S2STokenManager, S2STokenManagerConfigError, type S2STokenManagerOptions, S2STokenMintError, type SignS2SParams, type TokenIssuer, buildS2SClient, createClientAuthInterceptor, createS2SJwtSigner, waitForClientReady };