diff --git a/.changeset/2026-08-27-resilience-auth.md b/.changeset/2026-08-27-resilience-auth.md new file mode 100644 index 0000000..43900e2 --- /dev/null +++ b/.changeset/2026-08-27-resilience-auth.md @@ -0,0 +1,116 @@ +--- +'@dexpace/core': minor +--- + +Ship the authentication layer (product-spec §11, `AUTH-1`–`AUTH-38`) and promote the pillar-authoring surface +to the public barrel. **This is the first release with new public API since Phase 1.** + +`minor`, not `patch`: `packages/core/etc/core.api.md` gains the whole pipeline-authoring surface plus the auth +configuration types its signatures name, and `RequestOptions` gains one member. Nothing is removed or +narrowed, so no consumer breaks. + +## What a caller can now do + +```ts +import { + ApiKeyCredential, + createAuthDescriptor, + createAuthRequirement, + standardResilience, +} from '@dexpace/core'; + +const client = standardResilience(transport, { + auth: { + credentials: {apiKey: {credential: new ApiKeyCredential(process.env.API_KEY ?? '')}}, + tiers: {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + }, +}); +``` + +`standardResilience()` installs redirect, retry, and auth in that order — `AUTH-27`'s "redirect wraps retry +wraps auth" — so auth re-resolves and re-stamps per redirect hop and per retry attempt. `PipelineBuilder`, +`retryStep`, `redirectStep`, and `authStep` are exported for hand-assembling a pipeline instead, and +`PipelineBuilder.seedFrom(runtime, 'flatten' | 'nest')` composes one pipeline onto another with the choice +explicit rather than accidental (`PIPE-35`). + +## What landed + +The scheme-agnostic descriptor/resolver model (`AuthScheme`, `AuthRequirement`, `AuthDescriptor`, +`resolveAuthRequirement`), the credential types (`BearerToken`, `ApiKeyCredential`, `NameKeyCredential`, +`TokenProvider`), a total RFC 7235 challenge parser, a dependency-free MD5, the Basic/Digest/static-key +stamping handlers, a single-flight three-zone bearer token cache, and one AUTH pillar step tying them +together. `RequestOptions` gains `auth?: AuthDescriptor`, which fills `AUTH-4`'s most-specific `perCall` tier. + +Zero runtime dependencies still (`SEAM-1`). SHA-256 and the Digest client nonce go through +`globalThis.crypto`, and Basic stamping through `globalThis.btoa`, never `node:crypto` — the package stays +portable to browsers, Deno, and Workers. MD5 is hand-rolled because Web Crypto deliberately excludes it and +RFC 7616 still requires it for interop. + +## Design calls worth recording + +- **Basic and Digest never stamp preemptively.** Both are phrased in §11 entirely in terms of answering a + parsed challenge, and Digest structurally cannot stamp before seeing the server's `realm`/`nonce`. `OAUTH2` + and `API_KEY` do stamp preemptively; `NO_AUTH` never stamps. Flagged as an interpretation, not a certainty + — Phase 9's conformance sweep re-checks it. +- **One auth step with one pluggable challenge hook, not three mechanisms.** `AUTH-27` mandates exactly one + step, yet `AUTH-30`, `AUTH-23`–`AUTH-26`, and `AUTH-34`–`AUTH-37` read as three. Reconciled as one step, + one `challengeHook` extension point, and a scheme-dependent default body. A caller may override the hook + entirely — for a custom OAuth2 grant, say — and it takes precedence over every scheme default. +- **The cross-origin marker suppresses the WHOLE hop, not just the outbound pass.** The redirect step + (Phase 5b) marks a cross-origin re-issue; the auth step is that marker's intended consumer. It skips the + HTTPS guard, skips stamping, clears the marker so it never reaches the wire — and declines to answer a 401 + on that hop, because answering it would stamp exactly the credential the outbound pass withheld, onto a + server-chosen foreign host. +- **A `TokenProvider` takes no arguments and must carry its own deadline.** `AUTH-34` coalesces every + concurrent caller racing on a missing or expiring token onto ONE fetch, so that fetch belongs to no single + request — handing it one caller's signal would let a stranger's cancellation reject callers who never + aborted, and let a request that merely finished tear down a refresh others were joined to. Each caller + instead races its own wait against its own signal, cancelling the wait without cancelling the work. Because + nothing could ever populate a signal parameter, the type has none: write providers as + `() => fetchToken({signal: AbortSignal.timeout(5_000)})`. +- **`ChallengeHook` receives the call's signal.** Unlike a token fetch, a hook is not shared between callers, + so the same reasoning that withholds the signal above positively requires passing it here — a hook running a + custom OAuth2 refresh grant is network I/O on the request path. The hook's third parameter is optional and + additive: an existing two-argument hook still type-checks. `authStep` also declines to spend a second wire + send on the replay once the caller has aborted, and skips the hook entirely when the call was already + abandoned before the challenge arrived — matching the redirect and retry pillars. +- **A Digest challenge this client cannot echo is declined, not answered.** A received header may legally + carry non-ASCII (`Digest realm="café"` is a real RFC 7616 shape), but an outbound header value may not, and + loosening that is the request-splitting defence. Such a challenge is now reported as unsatisfiable, so the + 401 surfaces unchanged rather than the step throwing. A non-ASCII configured Digest *username* is caller + misconfiguration and is rejected up front; RFC 7616 `username*` encoding is not yet supported. +- **Every credential type is a nominal class that redacts its secret.** `BearerToken`, `ApiKeyCredential`, and + `NameKeyCredential` each hold their secret in a `#` field, so `console.log`, `util.inspect`, + `JSON.stringify`, and `Object.keys` all see a redacted form and never the value. Build them through + `createBearerToken`/the constructors — an object literal is not assignable, which is also what stops a + `TokenProvider` handing back a token that skipped the non-blank validation. +- **One clock for the whole pipeline.** `AuthStepSettings.clock` is the `now()` half of the same `Clock` + `RetryStepOptions.clock` takes, so one instance drives both pillars and a test cannot fake time for one and + forget the other. +- **`challengeHook` is the only challenge-reaction extension point.** There is deliberately no + `handlers` field: the built-in Basic and Digest handlers are internal, so a caller-supplied list could only + replace them wholesale, never compose with them. A hook covers the custom-scheme case with a shape a caller + can actually satisfy. +- **One bearer strategy, not two.** The reference ships a synchronous single-flight policy and a separate + async three-zone policy because it has two pipeline execution stories. This port has one, so the three-zone + policy ships unconditionally and `AUTH-34`'s non-blocking cached read is its fresh-zone branch. Same shape + as the retry engine's `RETRY-28` collapse. +- **`AUTH-31`'s replayability gate applies to every replacement — and gates only the replay.** The reference + gates only its sync step and recommends a port extend it; one unified step leaves exactly one place to apply + it. A non-replayable body skips the re-drive, but the challenge is still handled, so a 401 on a streaming + upload still evicts the token the server rejected instead of leaving it cached for every later request. +- **A refresh margin is validated, and so is a token's expiry.** `bearerMarginMs`, `BearerCredential.marginMs`, + and `createBearerToken`'s `expiresAt` must all be finite. Expiry is evaluated as `now + margin > expiresAt`, + which is `false` for `NaN` — an unvalidated margin (`Number(process.env.MARGIN_MS)` on an unset variable) + made the cache read a long-dead token as fresh and serve it forever without ever calling the provider again. +- **A failed background token refresh can never fail the request that triggered it.** `AUTH-37` says so + unconditionally, so the failure is swallowed unconditionally — including a programmer-error-shaped one. The + alternative re-raised it into a promise nobody awaits, which does not surface at the fault: it terminates the + host process asynchronously, unattributable to any request, while the request that triggered it had already + been served a valid token. +- **A failing response release never masks the error it was unwinding from.** If the challenge hook throws and + closing the 401's body then also fails, the hook's error stays primary and the teardown failure rides along + as `suppressed` (`RECOV-12`), matching the redirect and retry pillars. + +`standardResilience()` leaves the `LOGGING` slot empty — Phase 7b installs `loggingStep()` there and gives +`AUTH-37`'s failed-background-refresh case somewhere to be recorded. `SERDE` stays reserved. diff --git a/docs/open-items.md b/docs/open-items.md index 1a44bbb..188ad82 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -508,6 +508,79 @@ at code that exists where they say it does. --- +### G10 — Phase 5c publishes `Step`, the context family, and a PROVISIONAL `InstrumentationBundle` — **ACCEPTED RISK** + +5c's plan (Task 16 Step 5) lists exactly which symbols the public barrel gains. Two symbol groups had to be +promoted that the list does not name, because promoting `StepContext` and `StepDescriptor` forces them: + +- **`Step`** — `StepDescriptor.fn` is typed `Step`, so the type is reachable from a promoted signature. +- **`ExecutionContext`, `DispatchContext`, `RequestContext`, `ExchangeContext`, `InstrumentationBundle`** — + `StepContext.context` is typed `ExecutionContext`, which is a union ALIAS; api-extractor refuses to analyze + a re-exported union whose members are unexported, and a caller writing a custom step cannot type + `ctx.context` without them. + +**Narrowing `StepContext.context` was considered and rejected.** Cutting it down to `{readonly kind}` would +avoid publishing `InstrumentationBundle` — but `CTX-1` exists precisely so a step can read the exchange's +request and response, and every future logging and serde step needs that. Losing real capability to avoid +publishing a provisional type is the wrong trade. + +**The accepted risk is `InstrumentationBundle.activeSpan` and `.tracerFactory`.** Both are typed `unknown` +pending Phase 7a's tracing adapter. They are now documented as provisional *in the emitted `.d.ts`*, on the +interface and on each member, so a consumer reading either is warned in the same place they read the type. A +consumer warned in the declaration is the honest version of this tradeoff; publishing the type silently was +not. + +**Trigger:** Phase 7a. When the tracing adapter lands and those two members get concrete types, that is a +narrowing of what a caller receives and a widening of what they may pass — a `major` for anyone who read +either field. 7a owns that decision and the changeset wording for it. Phase 10 should confirm the promotion +as a whole was intended. + +### G11 — `DigestChallengeUnsupportedError` was speculative — **CLOSED: cut in Phase 5c** + +The design doc flagged this leaf as speculative and told the plan to cut it if no consumer materialized. None +did: `composingHandler()` returns `undefined` for an unsatisfiable challenge and `authStep()` leaves the 401 +unchanged either way, so nothing in `packages/core` ever constructed or caught it. Its stated reason to exist +— "for a caller driving `digestHandler()` directly" — was self-refuting, because `digestHandler` is internal +and absent from the barrel, so no caller could drive it directly. + +**Cut during Phase 5c's own shape review**, before it ever shipped: the class, its tests, its barrel export, +and the reference in `composing-handler.ts`'s doc comment are all gone. Removing an exported error class is a +breaking change, so doing it now cost nothing and doing it after release would have cost a `major`. If Phase +9's conformance sweep turns up a genuine need, adding it back is a `minor`. + +### G12 — `AUTH-37`'s failed-background-refresh logging is swallowed silently — **DEFERRED to Phase 7b** + +`AUTH-37` makes a failed background refresh non-fatal *and* expects it recorded. `bearer-cache.ts` swallows +the rejection explicitly and UNCONDITIONALLY — a bare `void` would leave an unhandled rejection that +terminates the process under Node's default policy, and the narrowed `catch` that briefly re-threw +`InvariantViolation` did exactly that, asynchronously and unattributable to any request, for a fault in +caller-supplied `TokenProvider` code. (That narrowing is gone; this entry described it for one revision +longer than the code did.) The log half has nowhere to go: no `Logger` seam exists until Phase 7b. + +**Trigger:** Phase 7b, alongside the `loggingStep()` install into `standardResilience()`'s empty `LOGGING` +slot and redirect's own three deferred emission sites. + +--- + +### G13 — two pre-existing cleanups Phase 5c's Reader pass found and deliberately did not take — **DEFERRED** + +Both predate 5c, sit in files Passes 1 and 2 declared settled, and were left alone rather than widening a +review pass into a refactor of earlier phases. + +1. **`hasForbiddenOutboundByte` breaks its own family's naming.** `packages/core/src/http/ascii-validation.ts` + exports `hasForbiddenNameByte`, `hasForbiddenInboundValueByte`, and `hasForbiddenOutboundByte` — the + outbound *value* predicate is the only one that omits `Value`. At `digest.ts:213` and `digest.ts:408` a + reader cannot tell from the call whether the name rule or the value rule is being applied, and the two + differ (HTAB is excepted by one and not the other). `hasForbiddenOutboundValueByte` restores the symmetry; + nine call sites outside the module. +2. **`PipelineBuilder`'s duplicated bucket lookup.** `insertAfter`, `insertBefore`, and `replace` each repeat + the same three lines — `const bucket = this.#buckets.get(anchor.stage);` plus an `invariant` whose message + is identical in all three. One `#requireBucket(stage)` collapses them. + +**Trigger:** whichever phase next edits `ascii-validation.ts` (1) or `pipeline/builder.ts` (2). + +--- + ## Maintaining this file Add an entry the moment a gap is found, not when it is fixed — the failure mode this file prevents is a diff --git a/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md b/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md new file mode 100644 index 0000000..bdc2db7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-phase5c-auth-checklist.md @@ -0,0 +1,230 @@ +# Phase 5c — Auth Implementation Plan — Checklist + +Verification of [2026-07-26-phase5c-auth.md](./2026-07-26-phase5c-auth.md) against every requirement ID in +`docs/product-spec/11-authentication.md` (`AUTH-1`–`AUTH-38`) plus `PIPE-2`, `PIPE-24`, `PIPE-35`, and +`PIPE-39`, as dispositioned by +`docs/superpowers/specs/2026-07-26-phase5c-auth-design.md`. + +**Status: EXECUTED (2026-08-27).** Every task below is implemented, tested, and green across the full gate +sequence (`typecheck`, `lint`, `build`, `bun test` with coverage, `api:ci`, `lint:publish`, +`verify:dual-consumption`, `verify:consumer-types`, `verify:seam-1`, `verify:runtime-floor`, `test:node`, +`audit`). + +**This is the one phase whose barrel and API report are EXPECTED to change.** Every prior phase asserted +`packages/core/src/index.ts` and `packages/core/etc/core.api.md` byte-identical to its starting point; 5c is +the first point a caller can assemble a working pipeline, so the pillar-authoring surface is promoted here. +See "Public-barrel promotion" below. + +**Phase 7b retrofit deliberately skipped.** The plan carries an amendment installing `loggingStep()` into the +preset's `LOGGING` slot, and its own 2026-07-29 correction says an agent executing this plan must skip those +blocks: 5c runs before 7b, so `observability/logging-step.js` does not resolve at this plan's execution time. +`standardResilience()` installs the three pillars that exist; Phase 7b's plan Task 9 installs the fourth. + +**Legend:** ✅ Implemented and tested — 🚫 Not built (permanent simplification, named reason) — ⏳ Deferred +(named target phase) — N/A Not applicable in this port. + +## Files shipped + +| File | Requirements | Task | +|---|---|---| +| `packages/core/src/auth/errors.ts` | `AUTH-6`, `AUTH-28`, `AUTH-35` | 1 | +| `packages/core/src/auth/scheme.ts` | `AUTH-1` | 2 | +| `packages/core/src/auth/requirement.ts` | `AUTH-2` | 3 | +| `packages/core/src/auth/descriptor.ts` | `AUTH-3` | 4 | +| `packages/core/src/auth/resolve.ts` | `AUTH-4`–`AUTH-7` | 5 | +| `packages/core/src/auth/credential.ts` | `AUTH-8`–`AUTH-11` | 6 | +| `packages/core/src/auth/challenge.ts` | `AUTH-12`, `AUTH-13` | 7 | +| `packages/core/src/auth/md5.ts` | `AUTH-15`, `AUTH-17` | 8 | +| `packages/core/src/auth/basic.ts` | `AUTH-14` | 9 | +| `packages/core/src/auth/digest.ts` | `AUTH-15`–`AUTH-22` | 10 | +| `packages/core/src/auth/static-key.ts` | `AUTH-26` | 11 | +| `packages/core/src/auth/composing-handler.ts` | `AUTH-23`–`AUTH-25` | 12 | +| `packages/core/src/auth/bearer-cache.ts` | `AUTH-34`–`AUTH-37` | 13 | +| `packages/core/src/auth/auth-step.ts` | `AUTH-27`–`AUTH-33`, `AUTH-36`, `AUTH-38` | 14 | +| `packages/core/src/auth/preset.ts` | `PIPE-24`, `PIPE-39` | 16 | +| `packages/core/src/http/request-options.ts` (amended) | `AUTH-4`'s `perCall` tier | 14 | +| `packages/core/src/pipeline/builder.ts` (amended) | `PIPE-35` | 15 | +| `packages/core/src/pipeline/runtime.ts` (amended) | `PIPE-35` (`get transport()`) | 15 | +| `packages/core/src/index.ts` (amended) | public-barrel promotion | 16 | +| `test/node-conformance/auth.test.mjs` | `AUTH-14`, `AUTH-15`, `AUTH-17`, `AUTH-20`, `AUTH-21`, `AUTH-30`–`AUTH-33` on Node | 17 | + +Every production file has a colocated `*.test.ts`. + +`test/node-conformance/auth.test.mjs` was not in the plan's file list. It is required by +`test/node-conformance/README.md`'s membership rule: 5c reaches three runtime-provided globals Bun implements +independently of Node — `crypto.subtle.digest`, `crypto.getRandomValues`, and `btoa` — and every one of them +fails silently rather than loudly if the runtimes disagree. A wrong SHA-256 digest is still a well-formed hex +string; a Latin-1/UTF-8 mismatch in Basic stamping is still valid-looking base64. + +## 11.1 The descriptor/resolver model + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-1 | MUST | The scheme set is exactly `{OAUTH2, API_KEY, BASIC, DIGEST, NO_AUTH}`, `NO_AUTH` a sentinel rather than a wire scheme | ✅ | Task 2 — a string-literal union, not a TS `enum` (`erasableSyntaxOnly`). The five members are enforced by the TYPE, exhaustively at every branch (`preemptiveStamp` and `defaultChallengeHook` both close on `assertNever`), which is what makes adding a sixth a compile error rather than a silent fall-through. A companion `AUTH_SCHEMES` array shipped briefly and was cut: nothing enumerated it, and its only test asserted the array's five members against the union's five — the constant restated, not a behaviour | +| AUTH-2 | MUST | A requirement binds one scheme to its own scopes and params; immutable against post-construction mutation of the inputs; value equality over all three | ✅ | Task 3 — `createAuthRequirement` spreads `scopes` and copies `params` into a new `Map`, then freezes; `authRequirementsEqual` compares scheme, ordered scopes, and params. Scope ORDER is part of the value, asserted directly | +| AUTH-3 | MUST | A descriptor is a non-empty ordered preference list, rejects an empty list at construction, is immutable, and reports `allowsAnonymous` iff some requirement is `NO_AUTH` | ✅ | Task 4 — the empty-list rejection is `invariant()`, **not** a typed leaf: a caller assembling zero requirements has a bug, not an operational failure (`docs/knowledge/error-handling.md`'s programmer/operational split). This corrects the design doc's "`ArgumentError` reused from earlier phases" — no such class exists in any prior phase | +| AUTH-4 | MUST | Tier selection is per-call, then operation, then client; the first PRESENT tier is resolved against and a present-but-unsatisfiable tier never falls through | ✅ | Task 5 (`perCall ?? operation ?? client`), asserted with a satisfiable lower tier present under an unsatisfiable higher one. Task 14 gives `perCall` a genuinely per-call source via `RequestOptions.auth` and `StepContext.options` | +| AUTH-5 | MUST | Within the selected descriptor, the first requirement whose scheme is `NO_AUTH` or in the supplied available set wins, without inspecting any concrete credential | ✅ | Task 5 — `availableSchemes` is a `ReadonlySet`, and Task 14's `availableSchemesOf()` derives it from which credentials are configured, so no credential value can reach the resolver | +| AUTH-6 | MUST | All tiers absent is an argument error; an unsatisfiable selected descriptor fails with a distinct error carrying the required schemes in preference order and the available schemes | ✅ | Task 1 (`AuthResolutionError.unsatisfiable`, both lists as `readonly` FIELDS, copied), Task 5. All-tiers-absent is `invariant()`, per AUTH-3's note above; the test asserts it is NOT an `AuthResolutionError` | +| AUTH-7 | MUST | The resolver is stateless, concurrency-safe, and a deterministic pure function | ✅ | Task 5 — a module-level function with no captured state; asserted by identity (the same inputs return the very object the descriptor already holds, so nothing is allocated per call) | + +## 11.2 Credentials + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-8 | MUST | Every credential redacts its secret in any string/diagnostic form without corrupting the real fields; bearer tokens have VALUE equality, key credentials REFERENCE identity | ✅ | Task 6 — **all three** credential types are classes holding their secret in a `#` field, each with `toString` AND `Symbol.for('nodejs.util.inspect.custom')`, because `console.log` does not route object arguments through `toString`. `#`, not TS `private`: redaction is a RUNTIME-privacy requirement, and `private` is erased, leaving the secret reachable through `Object.keys`/`JSON.stringify`/default inspect — all three asserted for all three types. `BearerToken` was a bare `{token, expiresAt}` object at first, which redacted NOTHING and failed this requirement outright; it keeps AUTH-8's VALUE equality through `bearerTokensEqual`, a pure function, exactly as the data object did. `ApiKeyCredential`/`NameKeyCredential` deliberately have NO `equals` override, so `===` already gives reference identity. None of the three exposes a public secret accessor: the static keys are read only through the internal `credentialKey()` friend hook, so no secret appears on the published `.d.ts` | +| AUTH-9 | MUST | Secret and identity fields validated non-blank at construction | ✅ | Task 6 — `invariant()` on all four (bearer token, API key, name-key name and key), asserted for `''` and whitespace-only. All three types are NOMINAL with private constructors, so the validation cannot be routed around: a `TokenProvider` returning an object literal no longer type-checks, which was reachable while `BearerToken` was a structural interface | +| AUTH-10 | MUST | Bearer expiry optional (absent = never locally expires), evaluated additively with a grace margin: expired iff `expiresAt` is set and `now + margin > expiresAt` | ✅ | Task 6 (`isBearerTokenExpired`), with the boundary asserted at `now === expiresAt` (NOT expired) and `now === expiresAt + 1` | +| AUTH-11 | MUST | Provider fetch errors propagate, are never cached, and reach an async caller through the async channel, never a synchronous throw | ✅ | Tasks 6 + 13 — the cache does not catch around the provider call, so this falls out of the structure rather than needing a branch; asserted by a rejecting provider followed by a clean refetch. `TokenProvider` takes no parameters at all, because a coalesced fetch belongs to no single call and nothing could correctly populate one — see the Deviation Ledger. A provider that fails SYNCHRONOUSLY (throwing before returning a promise, or returning a non-thenable) is normalized onto the async channel by `invokeProvider`, so `AUTH-38`'s uniform error model holds for it too; left bare it escaped past the background refresh's own `.catch` before that catch was attached | + +## 11.3 Challenge parsing + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-12 | MUST | Parse RFC 7235 challenge headers: multiple comma-separated challenges, quoted values containing commas and `=`, backslash escapes, lower-cased scheme and param names, verbatim unquoted values, a bare scheme with an empty map, a token68 under a synthetic key | ✅ | Task 7 — hand-written with a quote-depth scanner, never `.split(',')`. The synthetic key is spelled `'token68'`, the requirement's own wording. A token68's trailing `=` padding (`Negotiate YWJj==`) is disambiguated from an auth-param by requiring a real token or quoted-string value after the `=` — only at the scheme tail, the one position RFC 7235 permits a positional token68 | +| AUTH-13 | MUST | Total: never throws; blank input yields `[]`; a malformed challenge recovers at the next top-level comma; an unterminated quoted string ends at EOF; params before a malformed tail are preserved | ✅ | Task 7 — a `fast-check` property asserts totality over arbitrary strings; two more assert quoted-comma non-splitting and single-challenge round-tripping. A comma inside a MALFORMED segment's quoted value is also not a recovery point, asserted separately | + +## 11.4 Stamping handlers + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-14 | MUST | `Basic ` + base64(UTF-8(`username:password`)), computed once; `basic` accepted case-insensitively; `Authorization`/`Proxy-Authorization` chosen by the caller from which challenge header the status carried; credentials non-empty but whitespace PERMITTED per RFC 7617 | ✅ | Task 9 — the value is computed at construction and closed over; the laxer non-empty rule is deliberately NOT the credential types' `.trim()` check, asserted both ways. Case-insensitivity is implemented in `parseChallenges`, which lower-cases the scheme before any handler sees it | +| AUTH-15 | MUST | Digest supports exactly `{MD5, MD5-sess, SHA-256, SHA-256-sess}`, `qop=auth` or absent; declines `auth-int`-only, unsupported algorithms, and mutual-auth verification | ✅ | Task 10 — `SUPPORTED_ALGORITHMS` is the closed set; `auth-int`-only and `MD4` both asserted declined. Mutual auth (`Authentication-Info`) is never emitted or verified, which is the requirement's own disposition | +| AUTH-16 | MUST | Satisfiable iff scheme is `digest`, `realm`+`nonce` present, `qop` absent or containing `auth`, algorithm supported or absent (defaulting MD5), preferring the algorithm earliest in the CONFIGURED list regardless of wire order | ✅ | Task 10 (`parseDigestChallenge` + `rank`), Task 12 (`composingHandler` sorts by handler order then `rank`). `rank` is a plan-time addition to `ChallengeHandler`: `canHandle` alone answers yes/no per challenge and cannot express a preference among several a handler could equally satisfy — which is exactly what RFC 7616's repeated-challenge algorithm discovery produces | +| AUTH-17 | MUST | HA1/HA2/response per RFC 7616/2069, lower-case hex of the selected algorithm | ✅ | Tasks 8 + 10 — `computeDigestResponse` is exported and unit-tested against five independently-computed vectors (MD5 qop, MD5 no-qop, MD5-sess, SHA-256, SHA-256-sess) because `stamp()` draws a fresh random cnonce and can never be pinned end-to-end. MD5 is hand-rolled (Web Crypto excludes it) and checked against RFC 1321's own vectors plus the 55/56/64-byte padding boundaries | +| AUTH-18 | MUST | `nc` tracked per server nonce, starting at `00000001`, incrementing only on reuse, rendered as exactly 8 lower-case hex digits, low 32 bits on overflow | ✅ | Task 10 (`NonceCountStore`) — a `fast-check` property asserts strict monotonicity for a fixed nonce; a distinct nonce asserted to start fresh; a no-`qop` stamp asserted NOT to consume a count | +| AUTH-19 | SHOULD | The per-nonce store is bounded (default 1024) and drained under the cap; evicting a live nonce is harmless | ✅ | Task 10 — an insert-then-DRAIN-IN-A-LOOP, not a pre-insert single evict, per `docs/knowledge/concurrency-and-async.md`'s XCUT-14 rule for a server-keyed map. The distinguishing test bursts 4096 fresh nonces and asserts the map is at exactly the cap after every admit — a single-victim-per-insert store passes the "an evicted nonce restarts at 1" probe but fails this one | +| AUTH-20 | MUST | The client nonce comes from a cryptographically strong source with ≥128 bits of entropy | ✅ | Task 10 — `globalThis.crypto.getRandomValues()` over 16 bytes, never `Math.random()`; asserted 32 hex characters and distinct across calls, on Bun and again on Node | +| AUTH-21 | MUST | UTF-8 hash input when the challenge advertises `charset=UTF-8`, ISO-8859-1 otherwise | ✅ | Task 10 — asserted by a non-ASCII password hashing differently under the two, and identically for an all-ASCII input, on both runtimes | +| AUTH-22 | MUST | Quote/escape the appropriate fields, leave `qop`/`nc`/`algorithm` unquoted with the full algorithm spelling, use the request-target as the digest-uri, emit `cnonce`/`nc`/`qop` only when `qop` is negotiated | ✅ | Task 10 — `opaque` is echoed back quoted when the challenge carried one and omitted entirely otherwise (RFC 7616 requires the client return it unchanged; a server binding state to it rejects a request without it). A quote inside a realm asserted escaped. The digest-uri is `pathname + search`, asserted through the pillar step against `/a?q=1` | +| AUTH-23 | MUST | Composed handlers delegate to the first handler in DECLARATION order whose can-handle passes; the handler list is defensively copied | ✅ | Task 12 — handler order is the primary sort key and beats wire-order challenge position, asserted with `basic` first on the wire and `digest` first in configuration. A handler pushed onto the caller's array after construction is asserted invisible | +| AUTH-24 | MUST | Handlers are safe for concurrent invocation; a per-handler mutable counter such as Digest's `nc` yields correct, non-duplicated counts under concurrent reuse of one nonce | ✅ | Task 10 — `next()` is one synchronous read-increment-write with no `await` between the read and the write. Node and Bun have no preemptive interleaving mid-statement, so "thread-safe primitives" collapses to that, the same collapse 5a documented for `BODY-3` | +| AUTH-25 | MUST | `Authorization` for `WWW-Authenticate`, `Proxy-Authorization` for `Proxy-Authenticate`, selected by an explicit proxy flag; no header at all when nothing is satisfiable | ✅ | Task 12 (handlers return the VALUE half only), Task 14 (`pickChallengeHeader` reads only the header matching the STATUS, so a 401 carrying a stray `Proxy-Authenticate` is not answered — asserted). A 407 answered into `Proxy-Authorization` with `Authorization` absent is asserted end-to-end. The "explicit proxy flag" is `ChallengeSelection.isProxy` inside `auth-step.ts`, consumed by `answerHeaderName`; it is NOT threaded into `ChallengeHandler.stamp`, which was tried and removed — see the Deviation Ledger. `AUTH-28`'s replay guard covers `Proxy-Authorization` as well as `Authorization`, asserted separately | +| AUTH-26 | MUST | A static key is written into the configured header (default `Authorization`), prefixed by the configured prefix and exactly one space; stateless after construction | ✅ | Task 11 — uniform over both credential shapes; `NameKeyCredential.name` is deliberately NOT consulted as a header name (it is the non-secret half of the redacted `toString`; a caller wanting it passes `headerName` explicitly). An explicitly-empty prefix still contributes its space, so the option's absent state stays reachable | + +## 11.5 The AUTH pillar step + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-27 | MUST | Exactly one auth step at the single AUTH pillar stage, nested inside both the redirect and retry loops, so auth executes per redirect hop and per retry attempt | ✅ | Task 14 — `stage: 'AUTH'` is baked into the descriptor, so PIPE-36's "not relocatable out of its pillar" holds structurally; `PILLAR_STAGES` already caps the slot at one. Task 16's preset installs redirect-then-retry-then-auth, asserted by flattened stage order | +| AUTH-28 | MUST | On ANY path where a credential will be attached, reject a non-HTTPS URL (case-insensitive) BEFORE any token fetch or header stamping, with an error naming the concrete step and the offending scheme | ✅ | Task 14, **both paths**. Outbound: skipped for `NO_AUTH` (matching the requirement's own qualifier), asserted to fire before the provider is called. Replay: the outbound guard is skipped entirely for `NO_AUTH` and nothing constrains a caller hook to preserve the URL, so a replacement carrying a credential header is guarded again — and the challenge response is closed before the throw, so the body is not leaked | +| AUTH-29 | MUST | A cross-origin re-issue marked by the redirect step is not stamped, has the internal marker stripped so it never reaches the wire, and skips the HTTPS guard; a same-origin re-issue is re-stamped normally. The mechanism can only SUPPRESS, never force | ✅ | Task 14 — the marker is read first and cleared unconditionally before either branch, so it cannot survive into a request built by the stamping logic. **Both halves**: the outbound suppression AND the challenge-reaction suppression — a marked hop returns its 401 untouched and unclosed, because answering it would stamp exactly the credential the outbound pass declined to send, onto a server-chosen foreign host, over a URL whose HTTPS guard was skipped. Suppress-only holds structurally: nothing reads the marker to cause a stamp. Joint conformance in Task 16 | +| AUTH-30 | MUST | A 401 with `WWW-Authenticate` consults the challenge hook; a non-null replacement closes the original and drives once through a fresh chain copy, with no further challenge handling; the default hook yields no replacement | ✅ | Task 14 — reconciled as ONE step with one pluggable hook and a scheme-dependent default body, not three mechanisms. Every dispatch goes through a fresh `ctx.fork()`. A second 401 on the replay is returned as-is (asserted: exactly two wire sends, not a loop). `API_KEY`/`NO_AUTH` never react, which is the requirement's literal "the default hook yields no replacement" | +| AUTH-31 | MUST | The replay is gated on body replayability: a non-replayable replacement skips the replay, surfaces the original unchanged, and MUST NOT close it | ✅ | Task 14, applied **uniformly** — the reference gates only its sync step and recommends (SHOULD) a port extend it; one unified step leaves exactly one place to apply it, closing that SHOULD. The gate covers the DISPATCH only, for both hook shapes: the hook always runs, and its replacement is then gated on `body.replayable`. There is deliberately no "skip the hook for a one-shot body" fast path — one shipped briefly and skipped `AUTH-36`'s eviction with it, which is recorded against `AUTH-36` below. Asserted for the default hook and for a caller hook returning a non-replayable replacement, including the response left uncancelled | +| AUTH-32 | MUST | A hook that throws, rejects, or throws synchronously closes the open 401 before propagating | ✅ | Task 14 (`runHook`) — asserted for a rejecting hook AND a synchronously-throwing one, both on Bun and on Node. The close goes through 4b's `releaseQuietly`/`withReleaseFailure`, not a bare `await response.close()`: `Response.close()` rethrows whatever cancelling the body raised, so the bare form discarded the hook's own error and surfaced the teardown failure in its place — the inversion `RECOV-12` forbids, and the one 5b's `decideOrClose` already guards against. `guardReplayScheme` was fixed the same way, where the masked error was `PlaintextCredentialError`. Asserted against a body whose `cancel()` rejects | +| AUTH-33 | MUST | A 401 without `WWW-Authenticate` is returned unchanged without consulting the hook | ✅ | Task 14 — asserted for a bare 401, for a 401 carrying only `Proxy-Authenticate`, and for a hook returning `undefined`; all three leave the response uncancelled | +| AUTH-38 | SHOULD | The HTTPS-guard failure and any hook error are delivered through the async channel, not a synchronous throw | ✅ | Task 14, satisfied structurally: the step's `fn` is `async`, so both become a rejected promise with no separate code path. Asserted by a hook that throws synchronously still surfacing as a rejection | + +## 11.6 The bearer token cache + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| AUTH-34 | MUST | Stamp `Authorization: Bearer ` from a token cached until a configurable refresh margin (default 30 s) before expiry; concurrent requests racing on a missing/expiring token yield at most one provider fetch, with a non-blocking hot-path read of a valid cached token | ✅ | Task 13 — one unified policy, not two stacks; the hot-path read is the fresh-zone branch of `AUTH-37`'s state machine. See the Deviation Ledger. The 30 s default is `AuthStepSettings.bearerMarginMs`; single-flight coalescing asserted at exactly one provider invocation for two concurrent callers — and, separately, for a burst of concurrent POST-EVICTION refreshes, which an earlier `refreshNow()` shape turned into one provider call per 401 (a mass revocation would have stampeded the identity provider); the method is now named `refreshPostEviction()`, because it may JOIN a sibling 401's fetch and what it actually guarantees is that no pre-eviction fetch is ever joined. The 30 s default is pinned from BOTH sides — a token expiring just inside it refreshes, one just outside it does not — since a single one-sided assertion cannot tell 30 s from 60 s, and `BearerCredential.marginMs`'s override and an explicit `0` are each asserted for EFFECT, not only for validation. The margin is validated as a finite, non-negative duration at BOTH doors (`bearerMarginMs` and `BearerCredential.marginMs`) and `createBearerToken` rejects a non-finite `expiresAt`: `nowMs + marginMs > expiresAt` is false for `NaN`, so an unvalidated margin made the cache read a long-dead token as fresh and serve it from the hot path forever, never calling the provider again | +| AUTH-35 | MUST | Reject a null token and a token already expired at fetch time (no margin); never cache a thrown provider error | ✅ | Task 13 — the null guard is a RUNTIME check at a deliberately widened boundary, because a plain-JS caller can return null regardless of `TokenProvider`'s non-nullable type. A rejection propagates through `finally` untouched, so nothing is cached; asserted by a clean refetch after each failure mode | +| AUTH-36 | MUST | On a 401 advertising a Bearer challenge, evict ONLY the exact cached token that produced it (matched on the stamped header value), re-stamp a single retry with a freshly fetched token, preserve a token another request already refreshed, surface the 401 unchanged when the rejected request carried no `Authorization` or the response advertises no Bearer challenge, and fire regardless of HTTP method | ✅ | Tasks 13 + 14 — `evict()` compares `` `Bearer ${cached.token}` `` to the rejected header value and RETURNS the survivor on a mismatch, which the hook then stamps. That return is what makes the preservation clause observable: the first shape preserved the token and then unconditionally fetched a replacement, overwriting it on the next tick and reducing the clause to a no-op. Asserted end-to-end with a gated two-drive interleaving — the second 401 stamps the preserved token and the provider is called twice, not three times. No method check exists anywhere on this path; AUTH-31's replayability gate is what protects a non-replayable body — and that gate now covers the DISPATCH only. An earlier shape short-circuited the whole hook for a one-shot body, which skipped the eviction too and left the token the server had just rejected in the cache; with `AUTH-10`'s never-expiring token that never aged out either, so a stream-only client re-sent the dead credential indefinitely. Asserted by two successive one-shot POSTs, where the second carries a freshly fetched token | +| AUTH-37 | MUST | A three-zone expiry policy without blocking: fresh stamps with no refresh; expiring-but-valid stamps immediately and kicks off a background refresh; expired/missing awaits a single-flight fetch; concurrent expiring/missing callers coalesce; a failed fetch is not cached; a failed BACKGROUND refresh is non-fatal | ✅ | Task 13 — all three zones asserted separately. The background rejection is swallowed EXPLICITLY and UNCONDITIONALLY: a bare `void` would leave an unhandled rejection that terminates the process under Node's default policy, and the narrowed catch that briefly rethrew `InvariantViolation` did exactly that for a fault in caller-supplied provider code — see the Deviation Ledger. A synchronously-failing provider is normalized onto the async channel first, so the non-fatal guarantee is not conditional on HOW the provider failed. The post-eviction path is `refreshPostEviction()`. It does NOT bypass coalescing — an earlier shape did, and turned a mass revocation into one provider call per 401 — it supersedes only fetches predating this eviction burst, joining a sibling 401's fetch at the same generation (see the `AUTH-34` row). A generation counter also stops a superseded fetch re-caching its token if it settles LAST | + +## Cross-phase requirements closed here + +| ID | Level | Requirement gist | Status | Where | +|---|---|---|---|---| +| PIPE-2 | MUST | The pillar precedence chain, and specifically that auth executes per redirect hop and per retry attempt | ✅ | Task 16's joint conformance test — a `standardResilience()` runtime over a scripted `302 (cross-origin), 302 (back to seed origin), 200`: the credential is present on hop 1, ABSENT on the cross-origin hop, and RE-STAMPED on the return to the seed origin. 5a's own suite already covers the per-attempt dimension | +| PIPE-24 | MUST | The standard-resilience preset installs into empty pillar slots only, rejecting the whole call if any is occupied | ✅ *(satisfied VACUOUSLY — see the Deviation Ledger)* | Task 16 — true BY CONSTRUCTION: `standardResilience()` takes a `Transport` and always starts from a fresh `PipelineBuilder`, so no slot can be occupied. **The requirement's validate-and-reject half has no implementation, because no input can reach it** — that is recorded as a deviation rather than left implied, so Phase 9 does not look for a check that was never written. A caller layering the preset onto a customized builder reaches for `seedFrom` instead | +| PIPE-35 | SHOULD | Two unambiguous ways to seed from an existing pipeline — FLATTEN (copy steps and transport, same loops) vs NEST (opaque transport, separate loops) — with the choice explicit, never accidental (MUST) | ✅ | Task 15 — `mode` has no default value, so a caller cannot seed by accident. `flatten` re-buckets by each descriptor's OWN stage (asserted against seeded array position) and pillar collisions apply exactly as any append sequence. `nest` sets the runtime as the transport, asserted by the outer step running before the inner one and by both layers occupying the same pillar independently. `Runtime.transport` was added to make flatten implementable at all | +| PIPE-39 | SHOULD | Convenience constructors including a standard pipeline installing the default resilience pillars | ✅ | Task 16 — `standardResilience()` installs redirect (through 5b's `withRedirect`, which seats the `POST_AUTH` marker guard alongside), retry, and auth. Four descriptors total, asserted | + +## Public-barrel promotion + +`packages/core/src/index.ts` gains two groups, and `packages/core/etc/core.api.md` is regenerated to match. + +**Both pass conditions failed on the first attempt and are now enforced mechanically rather than asserted.** +This section previously claimed zero `ae-forgotten-export` warnings and a compile-only consumer smoke check +covering the promoted surface. Neither was true: the committed report carried a live +`ae-forgotten-export` for `ExecutionContext`, and `scripts/verify-consumer-types.mjs` still exercised only +Phase 3's body/response surface. The cause was a single word — an `@internal` token inside the prose comment +above the barrel's context-family export, which `stripInternal` (inherited from `gts/tsconfig-google.json`) +takes as an instruction to delete the whole export from the emitted `.d.ts`. `typecheck`, `build`, and +`api:ci` all passed over it, because api-extractor recorded the warning as report TEXT rather than failing. + +Both are now gates, not claims: + +- `packages/core/api-extractor.json` sets `ae-forgotten-export` to `logLevel: "error"` with + `addToApiReportFile: false`, so a forgotten export FAILS `api:ci` instead of being written into the report. +- `scripts/verify-consumer-types.mjs` compiles a consumer that names every promoted symbol — the context + family included — builds an `AuthStepSettings`, assembles both a hand-built `PipelineBuilder` pipeline and + a `standardResilience()` client, and annotates a custom `Step`, all importing ONLY from the package entry + point on the declared `lib` with `types: []`. + +- **Group 1, the authoring surface:** `Stage`, `STAGE_ORDER`, `PILLAR_STAGES`, `Step`, `StepContext`, `Next`, + `StepDescriptor`, `PipelineBuilder`, `Runtime`, `retryStep`, `redirectStep`, `authStep`, + `standardResilience`. +- **Group 2, everything those signatures name:** `RetryStepOptions`, `RetrySettings`, `BackoffSettings`, + `Clock`, `RedirectSettings`, `RedirectPredicate`, `RedirectCondition`, `StandardResilienceOptions`, + `AuthStepSettings`, `AuthCredentialSet`, `BasicCredential`, `DigestCredential`, `BearerCredential`, + `ApiKeyCredentialConfig`, `ChallengeHook`, `AuthTiers`, `AuthScheme`, `DigestAlgorithm`, `AuthDescriptor`, + `AuthRequirement`, `TokenProvider`, the factories + `createAuthDescriptor`/`createAuthRequirement`/`createBearerToken` and the equality helpers beside them, the + `ApiKeyCredential`/`NameKeyCredential`/`BearerToken` classes (all three NOMINAL — they carry a `#` field, so + no object literal substitutes, `API_KEY` would otherwise be unreachable, and a `TokenProvider` cannot return + a hand-built token that skips `AUTH-9`'s validation), and the two error leaves + `AuthResolutionError`/`PlaintextCredentialError`. +- **Deliberately NOT promoted, after review:** `Challenge`, `ChallengeHandler`, and `DigestUriContext`. They + were only reachable because `AuthStepSettings` carried a public `handlers` field — a field that promised + composability the package does not offer, since `basicHandler`/`digestHandler` stay internal, so supplying + one handler silently LOST the credential-derived ones. `handlers` was removed and the three types went back + to internal. `challengeHook` covers the custom-scheme case with a shape a caller can actually satisfy. + `DigestChallengeUnsupportedError` was cut for the same reason (`docs/open-items.md` G11). +- **Not in the plan's list, promoted as a forced consequence:** `Step`, and the whole context family + (`ExecutionContext`, `DispatchContext`, `RequestContext`, `ExchangeContext`, `InstrumentationBundle`). + `StepDescriptor.fn` names `Step`, and `StepContext.context` names `ExecutionContext`, which is a union + alias — api-extractor refuses to analyze it while its members are unexported. Promoting `StepContext` + without them would leave a caller unable to type a custom step's `ctx`. Narrowing `StepContext.context` + instead was considered and rejected — `CTX-1` exists so a step can read the exchange's request and response. + Recorded as an ACCEPTED RISK in `docs/open-items.md` G10, with `InstrumentationBundle`'s two provisional + `unknown` members documented as provisional in the emitted `.d.ts` itself. +- **Still internal:** everything else under `src/auth/` — `parseChallenges`, `md5.ts`, `basicHandler`, + `digestHandler`, `NonceCountStore`, `computeDigestResponse`, `composingHandler`, `BearerTokenCache`, + `stampStaticKey`, `availableSchemesOf`, `AUTH_STEP_TYPE` — plus 5b's `withRedirect`, + `stripCrossOriginMarkerStep`, and the cross-origin marker functions. A caller BUILDS an `AuthStepSettings` + from the Group 2 factories and hands it to `authStep()`/`standardResilience()`; it never constructs handler + internals. + +`RequestOptions` gains exactly one member, `auth?: AuthDescriptor`, and a matching builder method. + +## Deviation Ledger (for Phase 10) + +| Deviation | Reference behavior | Justification | +|---|---|---| +| One bearer strategy (async three-zone), not two | The reference ships a sync single-flight strategy and a separate async three-zone strategy | This port has one `Promise`-only execution model (4c), so `AUTH-34`'s hot-path read is a branch of `AUTH-37`'s state machine, not a second stack. Same reasoning and shape as 5a's `RETRY-28` collapse | +| `AUTH-31`'s replayability gate applied uniformly | The reference applies it on the sync auth step only, and SHOULDs a port extend it | One unified step leaves exactly one place to apply it; closes the spec's own SHOULD | +| Basic and Digest never stamp preemptively | Not stated either way in §11; inferred from `AUTH-14`/`AUTH-23`–`AUTH-25`'s exclusively challenge-driven phrasing | Digest structurally cannot stamp before seeing `realm`/`nonce`, and no separate "preemptive Basic" ID exists to contradict treating both uniformly. Flagged as an interpretation, not a certainty — Phase 9's conformance sweep should re-check it against any reference fixtures it turns up | +| `AUTH-3`/`AUTH-6` construction failures use `invariant()`, not a typed leaf | The design doc assumed an `ArgumentError` "reused from earlier phases" | No such class exists in any prior phase. Both cases are PROGRAMMER errors under `docs/knowledge/error-handling.md`'s split, which requires `invariant`/`assertNever`, not a handled error. A plan-time fix, not a deviation from working code | +| `TokenProvider` takes NO parameters; cancellation is caller-side, never provider-side | The reference's provider also takes no cancellation, so this ends up matching it exactly | Cancellation of a token fetch is caller-side by construction. `AUTH-34` makes the fetch SHARED by every caller coalesced onto it, so it is owned by no single call: handing it one caller's signal (a plan-time addition, shipped briefly as an optional `{signal}` bag) let a stranger's abort reject callers who never aborted — including one who supplied no signal at all — and let a request that merely finished tear down a refresh other requests were joined to. `bearer-cache.ts` races each caller's own WAIT against that caller's own signal instead, cancelling the wait without cancelling the work. Since nothing can ever populate a signal parameter, the parameter was cut rather than left documented-as-never-filled: a slot a caller writes code against and then finds inert is worse than no slot. `docs/knowledge/concurrency-and-async.md`'s "pass the caller's signal down to the I/O primitive" rule is deliberately not applied, because its premise — that the call owns the I/O — is false for a coalesced fetch; its "every external I/O call must carry a deadline" rule is discharged by `TokenProvider`'s TSDoc making an `AbortSignal.timeout` the provider's own obligation. **The type is back to the design doc's original shape**, `() => Promise` | +| `ChallengeHook` takes an options bag carrying the call signal | The reference's hook takes only the response and request | The hook is the sanctioned place for a custom OAuth2 refresh-token grant — network I/O on the request path — and unlike the token fetch it is NOT shared between callers, so the same rule that forbids handing a coalesced fetch one caller's signal positively requires handing the hook exactly that. Without it a hung hook pinned the auth step, every retry attempt nested under it, and the whole request. The parameter is optional and third, so an existing two-argument hook still type-checks. `authStep` checks the signal at two further points: BEFORE building or running the hook, so a call already abandoned when the challenge arrives never spends the default hook's IdP round trip (matching `redirectStep`'s pre-hop check), and again before the replay dispatch, for an abort that arrived while the hook was in flight. Both reads go through an `isAborted()` helper rather than an inline test — `AbortSignal.aborted` is a live getter, but TypeScript narrows it like an ordinary property and carries that narrowing across the `await`, so the second check does not compile when written inline. That is the compiler being confidently wrong about mutable external state, and `concurrency-and-async.md`'s re-validate-after-await rule is the one that governs | +| A Digest challenge whose echoed fields are not header-safe is DECLINED, not answered | `AUTH-22` says to quote and echo `realm`/`nonce`/`opaque`; it does not say what to do when they cannot be written | `HTTP-19` lets a received field-value carry obs-text, so `Digest realm="café"` — a real RFC 7616 shape, and the reason the spec has a `charset` parameter at all — arrives intact; `HTTP-18`'s outbound grammar will not let it back out, and relaxing that is off the table because it is the request-splitting defence. Building the header anyway threw `HeaderValidationError` out of the whole auth step, converting a challenge the caller could have inspected into an exception. `parseDigestChallenge` now declines, so `canHandle` is false and `AUTH-33` surfaces the 401 unchanged. **The consequence: `AUTH-21`'s UTF-8 branch is reachable for the HASH INPUT (a non-ASCII password works) but not for the realm ECHO.** A non-ASCII configured *username* is caller misconfiguration rather than wire data, so `digestHandler()` rejects it at construction instead | +| A failed background refresh is swallowed unconditionally, `InvariantViolation` included | `AUTH-37` says a failed background refresh MUST NOT fail the in-flight request (log-and-continue) | An earlier shape re-threw `InvariantViolation` from the fire-and-forget `.catch`, reasoning that a programmer error must crash loudly. That was wrong twice: the throw landed in a promise nobody awaits, so it did not surface at the fault — it killed the host process asynchronously, unattributable to any request, while the request that triggered it had already been served a valid token; and the fault it re-raised belongs to caller-supplied `TokenProvider` code, where a blank token is an operational fault (an empty environment variable, a malformed IdP payload) at least as often as a coding one. `docs/knowledge/error-handling.md`'s crash-loudly rule governs OUR invariants at the point WE detect them; it does not license re-raising someone else's failure into a detached promise | +| `docs/knowledge/error-handling.md` forbids "log and continue"; `AUTH-37` mandates it | `error-handling.md:22` — "a `catch` block must end in exactly one of three ways … 'log and continue' is none of these" | Standing, unresolved conflict between the styleguide and the normative spec, resolved in the spec's favour: `AUTH-37`'s clause is explicit and unconditional. The catch is blanket rather than narrowed to one expected type, which `error-handling.md:24` would also prefer otherwise, because the set of failures a caller-supplied provider can raise is not enumerable by this module. The LOG half is still missing and is tracked as a deferred item against Phase 7b | +| Duplicate auth-params within one challenge are last-wins | `AUTH-12` is silent on duplicates | RFC 7235's grammar does not admit them, so any input reaching this case is already malformed and `AUTH-13`'s leniency governs. `Map.set` gives last-wins for free; recorded because it is an unforced choice, not a derived one, and because parameter names are lower-cased first, so `realm` and `REALM` collide | +| `ChallengeHandler.stamp()` is async and takes an optional request context; `rank()` added | The design doc's prose gives `stamp()` a synchronous `string` return and no `rank` | SHA-256 Digest goes through `crypto.subtle.digest()`, which is asynchronous with no synchronous fallback; HA2 needs the method and request-target, which the challenge does not carry. `rank` is what expresses `AUTH-16`'s configured-preference-over-wire-order among several challenges one handler could equally satisfy | +| A generation counter guards the bearer cache against a superseded fetch | Not described either way | `refreshPostEviction()` drops the in-flight slot on the supersede branch, but the older fetch's own `then` would still publish its token into `cached` — re-caching exactly the token the server rejected whenever it settles after the fresh one. The counter is what makes `AUTH-37`'s "so the retry never re-sends the rejected token" hold in both resolution orders | +| `standardResilience()` installs only REDIRECT/RETRY/AUTH, not LOGGING | `docs/knowledge/pipeline.md`'s preset description includes instrumentation | Phase 7b has not shipped at this plan's execution point, and the plan's own 2026-07-29 correction routes the fourth `append` to 7b's Task 9. A scope boundary, not an omission | +| No async-variant preset | The reference's async standard pipeline (retry + instrumentation + caller-supplied scheduler) | 4c already dispositioned this port as one `Promise`-only execution model; there is no second pipeline to give a second preset to | +| The context family and `Step` promoted to public | Not addressed by the plan's Group 2 list | Forced by api-extractor: `StepDescriptor.fn` names `Step` and `StepContext.context` names the `ExecutionContext` union. See "Public-barrel promotion" above | +| `PIPE-24`'s validate-and-reject clause is structurally inexpressible here | `PIPE-24` requires the preset to validate up front that no target pillar is occupied and to reject the whole call, installing nothing, if any is | `standardResilience()` takes a `Transport`, not a builder or an existing pipeline, so it always starts from a fresh `PipelineBuilder` and no slot CAN be occupied. The requirement is satisfied vacuously — there is no code path implementing the validation, because there is no input that could fail it. Recorded so **Phase 9 does not hunt for a check that was never written**. A caller layering the preset onto a customized builder uses `PipelineBuilder.seedFrom(runtime, 'nest' \| 'flatten')`; if a future signature ever accepts a pre-populated builder, the validation becomes both expressible and mandatory | +| Helper functions sit ABOVE their callers in `auth-step.ts`, `bearer-cache.ts`, `digest.ts`, `challenge.ts` | `docs/knowledge/function-design.md` requires the step-down rule — each function above the functions it calls | Bottom-up (primitives first, the exported factory last) is the established shape of every step module since 5a's `retry-step.ts` and 5b's `redirect-step.ts`, and these four read as one family with them. Inverting four files to satisfy the rule costs more than the rule buys, and would leave 5c's modules the only ones ordered differently from their siblings. What WAS fixed is the inconsistency: `handleChallenge` was the single helper sitting below its caller, and now sits above it like the other sixteen | +| Several three-parameter functions take positional parameters, not an options object | `docs/knowledge/function-design.md` requires an options object at three or more parameters — stricter than this repo's `max-params: 3`, which they all pass | `createAuthRequirement(scheme, scopes, params)`, `digestHandler(username, password, options)`, `isBearerTokenExpired(token, nowMs, marginMs)` and `AuthResolutionError`'s `(message, requiredSchemes, availableSchemes)` all read unambiguously at their call sites, and every parameter is a distinct type, so no call can silently transpose two. The rule's real target is the boolean flag and the same-typed neighbour; both were fixed where they occurred — `hashHex` now takes a `HashInput` and `BearerTokenCache.refresh` takes an eviction generation rather than a `postEviction` boolean | +| `stamp` is the module's verb for a computation that RETURNS credential material rather than writing it | `docs/knowledge/naming-conventions.md` bars inventing a verb outside the client-verb taxonomy; `get`/`acquire` would be the sanctioned spellings | Seven symbols already share the vocabulary (`ChallengeHandler.stamp`, `ComposingHandler.stamp`, `stampStaticKey`, `BearerTokenCache.stamp`, `preemptiveStamp`, `stampContext`, and this checklist's own prose), and renaming half of them would leave the module reading in two dialects — worse than either end state. The vocabulary is now DEFINED once, in `ChallengeHandler`'s TSDoc: "to STAMP means to PRODUCE the value the caller writes, never to write it" | +| `ChallengeHandler.stamp` / `ComposingHandler.stamp` take no `isProxy` flag | `AUTH-25` phrases the origin-vs-proxy choice as "an explicit proxy flag" | The flag existed and was threaded from `auth-step.ts` through the composer into both handlers, and NEITHER read it: Basic's value is computed once at construction, and Digest's depends only on the challenge and the request-target. The only tests either could carry for it were tests asserting it changed nothing, which is a parameter kept alive by its own coverage. `AUTH-25`'s flag is still explicit — it is `ChallengeSelection.isProxy`, consumed by `answerHeaderName` in `auth-step.ts`, which is the one place that knows which challenge header the status carried. A future scheme whose VALUE varies by proxy-ness adds it back, and would then have something to assert | +| `AUTH-6`'s all-tiers-absent failure escapes the `DexpaceError` tree | `AUTH-6` calls for "an argument error" | It is an `InvariantViolation`, which extends `Error` directly, is internal-only, and is absent from the barrel — so a caller **cannot narrow on it**, and `catch (e) {if (e instanceof DexpaceError)}` misses it entirely. Sanctioned by the plan's Global Constraints (a caller with no tier configured has a bug, not an operational failure) and precedented by 5a's `retrySettings()` and 5b's `redirectSettings()`; recorded here because the residue is caller-visible, not merely internal. `authStep`'s `@throws` block DOES name it, precisely because a caller cannot narrow on it: the tag is the only place the failure mode is discoverable, so leaving it out would have hidden the one thing this row exists to record. (An earlier revision of this row claimed the opposite; the code was always the honest half.) | + +## Deferred Items (add to the roadmap's Deferred Items Log) + +| Item | Deferred from | Target | Reason | +|---|---|---|---| +| `standardResilience()` gains a `LOGGING` pillar step | Phase 5c | Phase 7b (Task 9) | No real logging step exists at 5c's execution point; the preset grows by one `append` | +| `AUTH-37`'s "log-and-continue" half for a failed background refresh | Phase 5c | Phase 7b | The failure is swallowed today — unconditionally, see the Deviation Ledger — because no `Logger` exists to record it. Until then a provider outage during a background refresh is invisible | +| Re-verification of the "Basic/Digest never preemptively stamp" reading against reference fixtures | Phase 5c | Phase 9 (conformance sweep) | Flagged as an interpretation in the Deviation Ledger, not a certainty | +| RFC 7616 §4 `username*` (RFC 5987) extended notation for a non-ASCII Digest username | Phase 5c | Unscoped | `digestHandler()` rejects a non-header-safe username at construction today. Implementing `username*` would let it be sent correctly rather than refused, and is the standard's own answer | +| A per-**operation** `AuthTiers` source | Phase 5c | Unscoped | `perCall` (via `RequestOptions.auth`) and `client` both have real sources as of Task 14; nothing in this roadmap ships a per-operation layer | +| ~~`DigestChallengeUnsupportedError` consumer confirmation~~ | Phase 5c | **CLOSED in 5c** | Cut before shipping rather than deferred: nothing constructed or caught it, and its stated purpose — a caller driving `digestHandler()` directly — was unreachable, since `digestHandler` is internal. Removing an exported error class is breaking, so cutting it now cost nothing. `docs/open-items.md` G11 | +| A caller-supplied `ChallengeHandler` list on `AuthStepSettings` | Phase 5c | Unscoped | `handlers` was removed at review: it forced three types onto the public barrel and could not compose with the built-in handlers, which stay internal. If a caller ever needs to ADD a handler rather than replace the whole reaction, the shape to ship is an append-semantics field plus public `basicHandler`/`digestHandler` factories — not the replace-semantics field that was cut | diff --git a/packages/core/api-extractor.json b/packages/core/api-extractor.json index 75aa61d..455423c 100644 --- a/packages/core/api-extractor.json +++ b/packages/core/api-extractor.json @@ -10,5 +10,13 @@ }, "dtsRollup": { "enabled": false + }, + "messages": { + "extractorMessageReporting": { + "ae-forgotten-export": { + "logLevel": "error", + "addToApiReportFile": false + } + } } } diff --git a/packages/core/etc/core.api.md b/packages/core/etc/core.api.md index bb60e45..ee19ab0 100644 --- a/packages/core/etc/core.api.md +++ b/packages/core/etc/core.api.md @@ -4,6 +4,106 @@ ```ts +// @public +export class ApiKeyCredential { + [INSPECT](): string; + constructor(key: string); + toString(): string; +} + +// @public +export interface ApiKeyCredentialConfig { + readonly credential: ApiKeyCredential | NameKeyCredential; + readonly headerName?: string | undefined; + readonly prefix?: string | undefined; +} + +// @public +export interface AuthCredentialSet { + readonly apiKey?: ApiKeyCredentialConfig | undefined; + readonly basic?: BasicCredential | undefined; + readonly bearer?: BearerCredential | undefined; + readonly digest?: DigestCredential | undefined; +} + +// @public +export interface AuthDescriptor { + readonly allowsAnonymous: boolean; + readonly requirements: readonly AuthRequirement[]; +} + +// @public +export interface AuthRequirement { + readonly params: ReadonlyMap; + readonly scheme: AuthScheme; + readonly scopes: readonly string[]; +} + +// @public +export function authRequirementsEqual(a: AuthRequirement, b: AuthRequirement): boolean; + +// @public +export class AuthResolutionError extends DexpaceError { + constructor(message: string, requiredSchemes?: readonly string[], availableSchemes?: readonly string[]); + readonly availableSchemes: readonly string[] | undefined; + readonly requiredSchemes: readonly string[] | undefined; + static unsatisfiable(requiredSchemes: readonly string[], availableSchemes: readonly string[]): AuthResolutionError; +} + +// @public +export type AuthScheme = 'OAUTH2' | 'API_KEY' | 'BASIC' | 'DIGEST' | 'NO_AUTH'; + +// @public +export function authStep(settings: AuthStepSettings): StepDescriptor; + +// @public +export interface AuthStepSettings { + readonly bearerMarginMs?: number | undefined; + readonly challengeHook?: ChallengeHook | undefined; + readonly clock?: Pick | undefined; + readonly credentials: AuthCredentialSet; + readonly tiers: AuthTiers; +} + +// @public +export interface AuthTiers { + readonly client?: AuthDescriptor | undefined; + readonly operation?: AuthDescriptor | undefined; + readonly perCall?: AuthDescriptor | undefined; +} + +// @public +export interface BackoffSettings { + readonly fixedDelayMs?: number | undefined; + readonly initialDelayMs: number; + readonly jitter: number; + readonly maxDelayMs: number; + readonly multiplier: number; +} + +// @public +export interface BasicCredential { + readonly password: string; + readonly username: string; +} + +// @public +export interface BearerCredential { + readonly marginMs?: number | undefined; + readonly provider: TokenProvider; +} + +// @public +export class BearerToken { + [INSPECT](): string; + readonly expiresAt: number | undefined; + get token(): string; + toString(): string; +} + +// @public +export function bearerTokensEqual(a: BearerToken, b: BearerToken): boolean; + // @public interface Body_2 { readonly contentLength: number; @@ -40,6 +140,18 @@ export class CancellationError extends DexpaceError { constructor(message: string, options?: ErrorOptions); } +// @public +export type ChallengeHook = (response: Response_2, request: Request_2, options?: { + readonly signal?: AbortSignal | undefined; +}) => Promise; + +// @public +export interface Clock { + monotonic(): number; + now(): number; + sleep(ms: number, signal?: AbortSignal): Promise; +} + // @public export function composeSignal(userSignal?: AbortSignal, timeoutMs?: number): AbortSignal | undefined; @@ -49,11 +161,37 @@ export class ConsumedBodyError extends DexpaceError { readonly bodyKind: string; } +// @public +export function createAuthDescriptor(requirements: readonly AuthRequirement[]): AuthDescriptor; + +// @public +export function createAuthRequirement(scheme: AuthScheme, scopes?: readonly string[], params?: ReadonlyMap): AuthRequirement; + +// @public +export function createBearerToken(token: string, expiresAt?: number): BearerToken; + // @public export class DexpaceError extends Error { constructor(message: string, options?: ErrorOptions); } +// @public +export type DigestAlgorithm = 'MD5' | 'MD5-sess' | 'SHA-256' | 'SHA-256-sess'; + +// @public +export interface DigestCredential { + readonly algorithmPreference?: readonly DigestAlgorithm[] | undefined; + readonly password: string; + readonly username: string; +} + +// @public +export interface DispatchContext { + readonly instrumentation: InstrumentationBundle; + readonly key: symbol; + readonly kind: 'dispatch'; +} + // @public export class DomainModelError extends DexpaceError { } @@ -72,6 +210,19 @@ export class ETag { export class EtagParseError extends DomainModelError { } +// @public +export interface ExchangeContext { + readonly instrumentation: InstrumentationBundle; + readonly key: symbol; + readonly kind: 'exchange'; + readonly operationName: string | undefined; + readonly request: Request_2; + readonly response: Response_2; +} + +// @public +export type ExecutionContext = DispatchContext | RequestContext | ExchangeContext; + // @public export class FormBodyValidationError extends DexpaceError { constructor(field: string, value: unknown, options?: ErrorOptions); @@ -160,6 +311,19 @@ export class HttpStatusError extends DexpaceError { readonly status: number; } +// @public +export interface InstrumentationBundle { + readonly activeSpan: unknown; + readonly isRemote: boolean; + readonly isValid: boolean; + readonly spanId: string; + readonly traceFlags: number; + readonly traceId: string; + readonly traceIdEncoding: string; + readonly tracerFactory: (operationName: string) => unknown; + readonly traceState: string; +} + // @public export function isBodyError(error: unknown): error is ConsumedBodyError | MultipartBoundaryError | FormBodyValidationError; @@ -225,6 +389,17 @@ export interface MultipartPart { readonly name: string; } +// @public +export class NameKeyCredential { + [INSPECT](): string; + constructor(name: string, key: string); + readonly name: string; + toString(): string; +} + +// @public +export type Next = (request?: Request_2) => Promise; + // @public export class OperationAssemblyError extends DexpaceError { constructor(message: string, parameterName: string); @@ -241,6 +416,32 @@ export interface OperationDescriptor { readonly query?: QueryParams | undefined; } +// @public +export const PILLAR_STAGES: ReadonlySet; + +// @public +export class PipelineBuilder { + constructor(transport: Transport); + append(descriptor: StepDescriptor): this; + appendAll(descriptors: readonly StepDescriptor[]): this; + build(): Runtime; + insertAfter(anchorType: symbol, descriptor: StepDescriptor): this; + insertBefore(anchorType: symbol, descriptor: StepDescriptor): this; + prepend(descriptor: StepDescriptor): this; + prependAll(descriptors: readonly StepDescriptor[]): this; + reload(descriptors: readonly StepDescriptor[]): this; + remove(type: symbol): this; + replace(anchorType: symbol, descriptor: StepDescriptor): this; + static seedFrom(runtime: Runtime, mode: 'flatten' | 'nest'): PipelineBuilder; +} + +// @public +export class PlaintextCredentialError extends DexpaceError { + constructor(stepName: string, scheme: string); + readonly scheme: string; + readonly stepName: string; +} + // @public export class Protocol { equals(other: Protocol): boolean; @@ -275,6 +476,29 @@ export class QueryParamsBuilder implements Builder { // @public export type RangeKind = 'bounded' | 'suffix' | 'open'; +// @public +export interface RedirectCondition { + readonly redirectsFollowed: number; + readonly response: Response_2; + readonly visited: ReadonlySet; +} + +// @public +export type RedirectPredicate = (condition: Readonly) => boolean; + +// @public +export interface RedirectSettings { + readonly allow303: boolean; + readonly allowedMethods: ReadonlySet; + readonly allowSchemeDowngrade: boolean; + readonly locationHeader: string; + readonly maxHops: number; + readonly predicate?: RedirectPredicate | undefined; +} + +// @public +export function redirectStep(overrides?: Partial): StepDescriptor; + // @public class Request_2 { get body(): Body_2 | undefined; @@ -321,8 +545,18 @@ export class RequestConditionsBuilder implements Builder { export class RequestConditionsValidationError extends DomainModelError { } +// @public +export interface RequestContext { + readonly instrumentation: InstrumentationBundle; + readonly key: symbol; + readonly kind: 'request'; + readonly operationName: string | undefined; + readonly request: Request_2; +} + // @public export class RequestOptions { + get auth(): AuthDescriptor | undefined; static readonly EMPTY: RequestOptions; get maxRetries(): number | undefined; static newBuilder(): RequestOptionsBuilder; @@ -333,6 +567,7 @@ export class RequestOptions { // @public export class RequestOptionsBuilder implements Builder { + auth(descriptor: AuthDescriptor | undefined): this; build(): RequestOptions; maxRetries(value: number | undefined): this; tags(entries: ReadonlyMap): this; @@ -376,6 +611,49 @@ export class ResponseBuilder implements Builder { status(status: Status): this; } +// @public +export interface RetrySettings extends BackoffSettings { + readonly attemptHeaderName?: string | undefined; + readonly maxAttempts: number; + readonly retryableStatuses: ReadonlySet; + readonly totalTimeoutMs?: number | undefined; +} + +// @public +export function retryStep(options?: RetryStepOptions): StepDescriptor; + +// @public +export interface RetryStepOptions { + readonly clock?: Clock | undefined; + readonly delayOverride?: ((attempt: number) => number | undefined) | undefined; + readonly random?: (() => number) | undefined; + readonly settings?: Partial | undefined; +} + +// @public +export class Runtime implements Transport { + close(): Promise; + send(request: Request_2, options?: RequestOptions, signal?: AbortSignal): Promise; + get steps(): readonly StepDescriptor[]; + get transport(): Transport; +} + +// @public +export type Stage = 'PRE_REDIRECT' | 'REDIRECT' | 'POST_REDIRECT' | 'PRE_RETRY' | 'RETRY' | 'POST_RETRY' | 'PRE_AUTH' | 'AUTH' | 'POST_AUTH' | 'PRE_LOGGING' | 'LOGGING' | 'POST_LOGGING' | 'PRE_SERDE' | 'SERDE' | 'POST_SERDE' | 'SEND'; + +// @public +export const STAGE_ORDER: readonly Stage[]; + +// @public +export function standardResilience(transport: Transport, options?: StandardResilienceOptions): Runtime; + +// @public +export interface StandardResilienceOptions { + readonly auth?: AuthStepSettings | undefined; + readonly redirect?: Partial | undefined; + readonly retry?: RetryStepOptions | undefined; +} + // @public export class Status { get code(): number; @@ -392,6 +670,25 @@ export class Status { static recognized(code: number): Status | undefined; } +// @public +export type Step = (request: Request_2, ctx: StepContext) => Promise; + +// @public +export interface StepContext { + readonly context: ExecutionContext; + readonly fork?: (() => Next) | undefined; + readonly next: Next; + readonly options?: RequestOptions | undefined; + readonly signal?: AbortSignal | undefined; +} + +// @public +export interface StepDescriptor { + readonly fn: Step; + readonly stage: Stage; + readonly type: symbol; +} + // @public export class StreamBody implements Body_2 { constructor(stream: ReadableStream, mediaType?: string, contentLength?: number); @@ -422,6 +719,9 @@ export function stringBody(text: string, mediaType?: string): StringBody; // @public export function toHttpError(response: Response_2): Promise; +// @public +export type TokenProvider = () => Promise; + // @public export interface Transport { close(): Promise; diff --git a/packages/core/src/auth/auth-step.test.ts b/packages/core/src/auth/auth-step.test.ts new file mode 100644 index 0000000..94873f4 --- /dev/null +++ b/packages/core/src/auth/auth-step.test.ts @@ -0,0 +1,1485 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/auth-step.test.ts +// Exercises: AUTH-27 (exactly one AUTH-stage descriptor, pinned to the pillar), AUTH-28 (HTTPS guard, +// NO_AUTH exempt, re-applied on the replay path), AUTH-29 (the cross-origin marker skips the guard and +// stamping, is cleared from the outbound headers, and suppresses the challenge reaction too -- so the +// credential cannot re-enter via the 401), AUTH-25 (a 407 is answered from Proxy-Authenticate into +// Proxy-Authorization), AUTH-30 (401 + WWW-Authenticate invokes the hook; a replacement re-drives +// exactly once through a fresh fork()), AUTH-31 (a non-replayable replacement body surfaces the +// original challenge unchanged and unclosed), AUTH-32 (a throwing hook closes the challenge response +// before propagating), AUTH-33 (no matching challenge header, or a hook yielding nothing -> unchanged), +// AUTH-36 (OAUTH2's default hook evicts the exact rejected token and re-stamps -- including behind a +// non-replayable body, where only the REPLAY is skipped), AUTH-4 (a per-call RequestOptions.auth +// descriptor overrides the configured tiers, via ctx.options), AUTH-5/AUTH-6 (resolution against the +// derived available-scheme set), RECOV-12 (a failing release never masks the primary error), +// AUTH-34/AUTH-35 (a refresh margin is validated as a finite, non-negative duration). +import {describe, expect, test} from 'bun:test'; +import {streamBody} from '../body/stream-body.js'; +import { + createRequestContext, + type ExecutionContext, +} from '../context/context.js'; +import {Headers} from '../http/headers.js'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {RequestOptions} from '../http/request-options.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {Cursor} from '../pipeline/cursor.js'; +import type {Transport} from '../seams/transport.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {CROSS_ORIGIN_MARKER_HEADER} from '../redirect/cross-origin.js'; +import type {SuppressedErrorLike} from '../suppress.js'; +import {invariant} from '../invariant.js'; +import {FakeTransport, countingResponse} from '../testing/fake-transport.js'; +import { + AUTH_STEP_TYPE, + authStep, + availableSchemesOf, + type AuthCredentialSet, +} from './auth-step.js'; +import {createBearerToken, ApiKeyCredential} from './credential.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {AuthResolutionError, PlaintextCredentialError} from './errors.js'; +import {createAuthRequirement} from './requirement.js'; +import type {AuthScheme} from './scheme.js'; + +// Constructed inline rather than imported: 4c keeps `aRequestContext()` file-local to `cursor.test.ts`, +// and importing across `*.test.ts` files is not acceptable -- the same call 5a's and 5b's step suites made. +function aRequestContext(request: Request): ExecutionContext { + return createRequestContext(request); +} + +function aRequest(url = 'https://example.com/a'): Request { + return Request.newBuilder().url(url).build(); +} + +function markedRequest(url: string): Request { + return Request.newBuilder() + .url(url) + .headers(Headers.newBuilder().add(CROSS_ORIGIN_MARKER_HEADER, '1').build()) + .build(); +} + +/** The optional per-drive inputs, bundled so `runThrough` stays within `max-params`. */ +interface DriveOverrides { + readonly request?: Request | undefined; + readonly options?: RequestOptions | undefined; + readonly signal?: AbortSignal | undefined; +} + +// `Transport`, not `FakeTransport`: the only thing this helper does with it is hand it to `Cursor`, +// and narrowing to what is actually used is what lets the gated double below be driven through it too +// (`docs/knowledge/api-design.md` -- accept the narrowest interface describing the members used). +function runThrough( + descriptor: StepDescriptor, + transport: Transport, + overrides: DriveOverrides = {}, +): Promise { + const request = overrides.request ?? aRequest(); + return new Cursor({ + steps: [descriptor], + transport, + request, + context: aRequestContext(request), + options: overrides.options, + signal: overrides.signal, + }).advance(); +} + +function tiersFor(scheme: AuthScheme): { + client: ReturnType; +} { + return {client: createAuthDescriptor([createAuthRequirement(scheme)])}; +} + +/** + * A challenge response: `countingResponse` plus the challenge header. `ResponseBuilder` carries the + * SAME body instance through `newBuilder()`, so the rebuilt response still reports through the + * original's release counter. `setInbound`, not `set`: these are inbound headers, and a real server may + * send obs-text in a realm (HTTP-19). + */ +function challengeResponse( + status: number, + headerName: string, + headerValue: string, +): {response: Response; cancelCount: () => number} { + const base = countingResponse(status); + const response = base.response + .newBuilder() + .headers( + base.response.headers + .newBuilder() + .setInbound(headerName, headerValue) + .build(), + ) + .build(); + return {response, cancelCount: base.cancelCount}; +} + +/** A one-shot request body: `StreamBody.replayable` is `false` (AUTH-31's gate). */ +function oneShotPost(url = 'https://example.com/a'): Request { + const stream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + return Request.newBuilder() + .method('POST') + .url(url) + .body(streamBody(stream, 'text/plain', 0)) + .build(); +} + +const CANCEL_FAILURE = new Error('cancel exploded'); + +/** + * A 401 whose body `cancel()` REJECTS with a non-`TypeError` -- the one thing `Response.close()` is + * documented to rethrow. Models a transport releasing over an already-broken socket. Same shape 5b's + * `redirect-step.test.ts` uses for its own RECOV-12 coverage. + */ +function hostileChallenge(value = 'Basic realm="x"'): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1])); + }, + cancel() { + throw CANCEL_FAILURE; + }, + }); + return Response.newBuilder() + .request(aRequest()) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(401)) + .headers(Headers.newBuilder().setInbound('WWW-Authenticate', value).build()) + .body(body) + .build(); +} + +/** + * A macrotask boundary, so a fire-and-forget background refresh's whole then/finally chain has + * drained regardless of how many microtask hops it takes. Same helper `bearer-cache.test.ts` uses. + */ +function drainMacrotask(): Promise { + return new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +/** + * Drives three requests through `descriptor`, draining between them, and reports the `Authorization` + * value each one actually put on the wire. + * + * Three drives is the shortest sequence that can observe a refresh MARGIN at all: the first fills an + * empty cache (where no margin is consulted), the second is the one the margin either does or does + * not push into AUTH-37's expiring-but-valid zone, and the third reveals whether that zone's + * background refresh actually happened. + */ +async function stampsOverThreeDrives( + descriptor: StepDescriptor, +): Promise { + const transport = new FakeTransport([ + countingResponse(200).response, + countingResponse(200).response, + countingResponse(200).response, + ]); + for (let drive = 0; drive < 3; drive += 1) { + await runThrough(descriptor, transport); + await drainMacrotask(); + } + return transport.calls.map(call => call.request.headers.get('Authorization')); +} + +/** + * A provider issuing `t1` at `firstExpiresAt` and then `t2` far out of any margin's reach, counting + * its calls. Every test below pins the clock at 0, so `firstExpiresAt` IS t1's remaining lifetime. + */ +function agingTokenProvider(firstExpiresAt: number): { + readonly credentials: AuthCredentialSet; + readonly callCount: () => number; +} { + let issued = 0; + return { + credentials: { + bearer: { + provider: () => { + issued += 1; + return Promise.resolve( + createBearerToken( + `t${String(issued)}`, + issued === 1 ? firstExpiresAt : 10_000_000, + ), + ); + }, + }, + }, + callCount: () => issued, + }; +} + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('availableSchemesOf (AUTH-5)', () => { + test('is empty for an empty credential set', () => { + expect([...availableSchemesOf({})]).toEqual([]); + }); + + test('maps each configured credential to its scheme', () => { + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + digest: {username: 'u', password: 'p'}, + bearer: {provider: () => Promise.resolve(createBearerToken('t'))}, + apiKey: {credential: new ApiKeyCredential('k')}, + }; + expect([...availableSchemesOf(credentials)].sort()).toEqual([ + 'API_KEY', + 'BASIC', + 'DIGEST', + 'OAUTH2', + ]); + }); +}); + +describe('authStep: resolution and the preemptive stamp (AUTH-26..AUTH-28, AUTH-34)', () => { + test('is pinned to the AUTH pillar stage (AUTH-27)', () => { + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + expect(descriptor.stage).toBe('AUTH'); + expect(descriptor.type).toBe(AUTH_STEP_TYPE); + }); + + test('NO_AUTH stamps nothing and never triggers the HTTPS guard, even over plain HTTP (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + + await runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }); + + expect( + transport.calls[0]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); + + test('API_KEY stamps preemptively via the configured header/prefix (AUTH-26)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: { + credential: new ApiKeyCredential('secret'), + headerName: 'X-Api-Key', + }, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + + await runThrough(descriptor, transport); + + expect(transport.calls[0]?.request.headers.get('X-Api-Key')).toBe('secret'); + }); + + test('OAUTH2 stamps a cached bearer token preemptively (AUTH-34)', async () => { + const transport = new FakeTransport([ + countingResponse(200).response, + countingResponse(200).response, + ]); + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + await runThrough(descriptor, transport); + await runThrough(descriptor, transport); + + expect(transport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', + ); + // The second call reads the still-fresh cached token rather than refetching. + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', + ); + expect(calls).toBe(1); + }); +}); + +describe('authStep: the HTTPS guard and tier resolution (AUTH-6/AUTH-28)', () => { + test('a credentialed scheme over plain HTTP throws PlaintextCredentialError before any send (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + + const error = await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(error).toBeInstanceOf(PlaintextCredentialError); + expect((error as PlaintextCredentialError).scheme).toBe('API_KEY'); + expect(transport.sendCount).toBe(0); + }); + + test('the guard fires before the token fetch, not after (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + let fetched = false; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + fetched = true; + return Promise.resolve(createBearerToken('t', 100_000)); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(fetched).toBe(false); + }); + + test('an unsatisfiable tier surfaces AuthResolutionError (AUTH-6)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('BASIC')}); + + const error = await rejectionOf(runThrough(descriptor, transport)); + + expect(error).toBeInstanceOf(AuthResolutionError); + expect(transport.sendCount).toBe(0); + }); + + test('BASIC/DIGEST never stamp preemptively -- the outbound request carries no Authorization', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + await runThrough(descriptor, transport); + + expect( + transport.calls[0]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); +}); + +describe('authStep: the cross-origin marker (AUTH-29)', () => { + test('AUTH-29: a cross-origin-marked request skips the guard and stamping, marker cleared', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + // Plain HTTP -- would normally trip the guard, but the marker skips it (AUTH-29). + const marked = markedRequest('http://example.com/a'); + + await runThrough(descriptor, transport, {request: marked}); + + const sent = transport.calls[0]?.request; + expect(sent?.headers.get('Authorization')).toBeUndefined(); + expect(sent?.headers.has(CROSS_ORIGIN_MARKER_HEADER)).toBe(false); + }); + + test('AUTH-29: the marker is cleared even on the ordinary same-origin path', async () => { + // An unmarked request has nothing to clear, but a marked HTTPS request on a stamping path must + // still not forward the header -- clearing happens before the branch, not inside one of them. + const transport = new FakeTransport([countingResponse(200).response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + + await runThrough(descriptor, transport, { + request: markedRequest('https://example.com/a'), + }); + + expect( + transport.calls[0]?.request.headers.has(CROSS_ORIGIN_MARKER_HEADER), + ).toBe(false); + }); + + test('AUTH-29: a cross-origin-marked request does NOT answer a challenge either', async () => { + // The suppression covers the whole hop. Answering the challenge here would stamp exactly the + // credential the outbound pass declined to send, onto the server-chosen foreign host, over a URL + // whose HTTPS guard was skipped -- the precise leak AUTH-29 exists to prevent. + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport, { + request: markedRequest('http://evil.example/a'), + }); + + expect(transport.sendCount).toBe(1); // no re-drive was attempted + expect(response).toBe(challenged.response); // unchanged and unclosed -- the caller owns it + expect(challenged.cancelCount()).toBe(0); + }); +}); + +describe('authStep: challenge detection (AUTH-25/AUTH-33)', () => { + test('a 407 is answered from Proxy-Authenticate into Proxy-Authorization (AUTH-25)', async () => { + const challenged = challengeResponse( + 407, + 'Proxy-Authenticate', + 'Basic realm="p"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(2); + expect( + transport.calls[1]?.request.headers + .get('Proxy-Authorization') + ?.startsWith('Basic '), + ).toBe(true); + expect( + transport.calls[1]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); + + test('a 401 carrying only Proxy-Authenticate is NOT answered (AUTH-25)', async () => { + const challenged = challengeResponse( + 401, + 'Proxy-Authenticate', + 'Basic realm="p"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + }); +}); + +describe('authStep: challenge detection, negative cases (AUTH-33)', () => { + test('a 401 without WWW-Authenticate is returned unchanged (AUTH-33)', async () => { + const the401 = countingResponse(401); + const transport = new FakeTransport([the401.response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(response).toBe(the401.response); + expect(transport.sendCount).toBe(1); + expect(the401.cancelCount()).toBe(0); + }); + + test('a non-challenge status is returned untouched', async () => { + const success = countingResponse(200); + const transport = new FakeTransport([success.response]); + const descriptor = authStep({credentials: {}, tiers: tiersFor('NO_AUTH')}); + + expect(await runThrough(descriptor, transport)).toBe(success.response); + }); +}); + +describe('authStep: the challenge replay (AUTH-30/AUTH-31)', () => { + test('a 401 with a Basic challenge re-drives exactly once with the stamped Authorization (AUTH-30)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(2); + expect( + transport.calls[1]?.request.headers + .get('Authorization') + ?.startsWith('Basic '), + ).toBe(true); + expect(response).toBe(success.response); + expect(challenged.cancelCount()).toBe(1); // AUTH-30: the original is closed before the re-drive + }); + + test('no nested re-challenge: a second 401 on the replay is returned as-is (AUTH-30)', async () => { + const first = challengeResponse(401, 'WWW-Authenticate', 'Basic realm="x"'); + const second = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([first.response, second.response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(2); // exactly one replay, not a loop + expect(response).toBe(second.response); + expect(second.cancelCount()).toBe(0); // the surfaced response is the caller's, left open + }); +}); + +describe('authStep: answering a Digest challenge (AUTH-15..AUTH-22)', () => { + test('a Digest challenge is answered with a Digest header value (AUTH-15..22)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Digest realm="r", nonce="n", qop="auth"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + digest: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('DIGEST')}); + + await runThrough(descriptor, transport, { + request: aRequest('https://example.com/a?q=1'), + }); + + const value = transport.calls[1]?.request.headers.get('Authorization'); + expect(value?.startsWith('Digest ')).toBe(true); + // AUTH-22: the digest-uri is the request-target, path AND query. + expect(value).toContain('uri="/a?q=1"'); + }); +}); + +describe('authStep: the replayability gate (AUTH-31)', () => { + test('an unsatisfiable challenge leaves the response unchanged (AUTH-25/AUTH-33)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Negotiate abc123', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + expect(challenged.cancelCount()).toBe(0); + }); + + // AUTH-31 gates the DISPATCH only. The hook still runs for a one-shot body -- there is deliberately + // no "skip the hook when the body is one-shot" fast path (see `handleChallenge`), because OAUTH2's + // default hook evicts the rejected token on the way past and that work is not wasted. What this + // test pins is the replay gate's own three obligations; the eviction half is pinned separately by + // 'a revoked token is evicted even though the replay is skipped' below. + test('a non-replayable body surfaces the original 401 unchanged and unclosed, with no replay (AUTH-31)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const credentials: AuthCredentialSet = { + basic: {username: 'u', password: 'p'}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('BASIC')}); + + const response = await runThrough(descriptor, transport, { + request: oneShotPost(), + }); + + expect(response).toBe(challenged.response); + expect(transport.sendCount).toBe(1); // no replacement dispatch was attempted + expect(challenged.cancelCount()).toBe(0); // the caller owns it -- MUST NOT be closed + }); + + test('AUTH-31 also gates a caller hook that returns a non-replayable replacement', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.resolve(oneShotPost()), + }); + + const response = await runThrough(descriptor, transport); + + expect(response).toBe(challenged.response); + expect(transport.sendCount).toBe(1); + expect(challenged.cancelCount()).toBe(0); + }); +}); + +describe('authStep: challenge-hook failure and override (AUTH-30/AUTH-32/AUTH-33)', () => { + test('a throwing challengeHook closes the 401 before propagating (AUTH-32)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.reject(new Error('hook exploded')), + }); + + const error = await rejectionOf(runThrough(descriptor, transport)); + + expect((error as Error).message).toBe('hook exploded'); + expect(challenged.cancelCount()).toBe(1); + }); + + test('a hook throwing SYNCHRONOUSLY also closes the 401 (AUTH-32/AUTH-38)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (): Promise => { + throw new Error('sync boom'); + }, + }); + + const error = await rejectionOf(runThrough(descriptor, transport)); + + expect((error as Error).message).toBe('sync boom'); + expect(challenged.cancelCount()).toBe(1); + }); + + test('a hook yielding nothing leaves the 401 unchanged and unclosed (AUTH-33)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.resolve(undefined), + }); + + const response = await runThrough(descriptor, transport); + + expect(response).toBe(challenged.response); + expect(challenged.cancelCount()).toBe(0); + }); +}); + +describe('authStep: hook override and non-reactive schemes (AUTH-30)', () => { + test('a caller-supplied challengeHook takes precedence over the scheme default (AUTH-30)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + let hookInvoked = false; + const descriptor = authStep({ + credentials: {basic: {username: 'u', password: 'p'}}, + tiers: tiersFor('BASIC'), + challengeHook: (_response, request) => { + hookInvoked = true; + return Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Custom xyz') + .build(), + ) + .build(), + ); + }, + }); + + await runThrough(descriptor, transport); + + expect(hookInvoked).toBe(true); + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Custom xyz', + ); + }); +}); + +describe('authStep: schemes with no reactive behavior (AUTH-30)', () => { + test('API_KEY does not react to a 401 -- static credentials have no reactive behavior (AUTH-30)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + }); +}); + +describe('authStep: the OAUTH2 default hook (AUTH-36)', () => { + test('OAUTH2 default hook evicts the exact rejected token and re-stamps (AUTH-36)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Bearer realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + await runThrough(descriptor, transport); + + expect(transport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', + ); + // Evicted t1, fetched genuinely fresh. + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Bearer t2', + ); + expect(calls).toBe(2); + }); +}); + +/** + * Holds one nominated send until {@link GatedTransport.release} is called, so a two-drive + * interleaving can be pinned instead of left to the scheduler. Everything else delegates to the + * scripted double. + */ +class GatedTransport implements Transport { + readonly #inner: FakeTransport; + readonly #gatedEntry: number; + #entered = 0; + #release: (() => void) | undefined; + readonly #gate: Promise; + + constructor(inner: FakeTransport, gatedEntry: number) { + this.#inner = inner; + this.#gatedEntry = gatedEntry; + this.#gate = new Promise(resolve => { + this.#release = resolve; + }); + } + + release(): void { + this.#release?.(); + } + + async send(request: Request): Promise { + // Counted on ENTRY, not off the inner double's `sendCount`: a gated call has not reached the + // inner transport yet, so `sendCount` would still be pointing at the gated position and every + // later call would gate too -- a deadlock, which is exactly what the first shape of this did. + this.#entered += 1; + if (this.#entered === this.#gatedEntry) await this.#gate; + return this.#inner.send(request); + } + + async close(): Promise { + // Nothing to release; the inner double owns no resources. + } +} + +describe('authStep: OAUTH2 preserves a token another request refreshed (AUTH-36)', () => { + test('a 401 on a token the cache has already replaced stamps the survivor, with no second fetch', async () => { + // AUTH-36's "preserving a token another request already refreshed", at the seam where it is + // actually observable. Two drives both stamp `t1` off one single-flight fetch. Drive A's 401 + // runs to completion first -- evicting `t1` and caching `t2` -- and only then is drive B's 401 + // released. B's rejected header (`t1`) no longer matches the cache (`t2`), so the eviction + // PRESERVES `t2` and the retry stamps it. Burning a third provider call to re-derive the same + // token, which the earlier unconditional-`refreshNow()` shape did, is what makes the clause a + // no-op rather than a behaviour. + // Scripted in the order the inner double actually SEES them, which the gate pins: A's 401, A's + // replay, then B's 401 and B's replay once released. + const inner = new FakeTransport([ + challengeResponse(401, 'WWW-Authenticate', 'Bearer realm="x"').response, + countingResponse(200).response, + challengeResponse(401, 'WWW-Authenticate', 'Bearer realm="x"').response, + countingResponse(200).response, + ]); + const transport = new GatedTransport(inner, 2); // hold drive B's first send + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const driveA = runThrough(descriptor, transport); + const driveB = runThrough(descriptor, transport); + await driveA; + transport.release(); + await driveB; + + expect(calls).toBe(2); // the initial fetch and A's post-eviction fetch. B fetched nothing. + expect(inner.calls[2]?.request.headers.get('Authorization')).toBe( + 'Bearer t1', // B's original stamp, the one the server rejected + ); + expect(inner.calls[3]?.request.headers.get('Authorization')).toBe( + 'Bearer t2', // the PRESERVED token, stamped without a third fetch + ); + }); +}); + +describe('authStep: OAUTH2 declines a non-Bearer challenge (AUTH-36)', () => { + test('OAUTH2 leaves a 401 unchanged when it advertises no Bearer challenge (AUTH-36)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + let calls = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + calls += 1; + return Promise.resolve( + createBearerToken(`t${String(calls)}`, 100_000), + ); + }, + }, + }; + const descriptor = authStep({ + credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const response = await runThrough(descriptor, transport); + + expect(transport.sendCount).toBe(1); + expect(response).toBe(challenged.response); + expect(calls).toBe(1); // no eviction-driven refetch + }); +}); + +describe('authStep: the replay HTTPS guard (AUTH-28)', () => { + test('AUTH-28 is re-applied to a challenge replacement that carries a credential', async () => { + // The outbound guard is SKIPPED for NO_AUTH, and nothing constrains a caller hook to preserve the + // URL -- so without a second guard a hook answering a challenge stamps a credential over plaintext. + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), // outbound guard skipped entirely + challengeHook: (_response, request) => + Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Basic c3B5') + .build(), + ) + .build(), + ), + }); + + const error = await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(error).toBeInstanceOf(PlaintextCredentialError); + expect(transport.sendCount).toBe(1); // the replacement never reached the wire + expect(challenged.cancelCount()).toBe(1); // and the 401 was closed before the throw, not leaked + }); + + test('a credential-free replacement over plaintext is NOT blocked by the replay guard (AUTH-28/AUTH-29)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request) => + Promise.resolve( + request.newBuilder().url('http://example.com/b').build(), + ), + }); + + const response = await runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }); + + expect(transport.sendCount).toBe(2); + expect(response).toBe(success.response); + }); +}); + +describe('authStep: per-call configuration and injected seams (AUTH-4/AUTH-11)', () => { + test('a per-call RequestOptions.auth descriptor overrides the configured tiers (AUTH-4)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: { + credential: new ApiKeyCredential('secret'), + headerName: 'X-Api-Key', + }, + }; + // Configured tiers resolve to API_KEY; the per-call descriptor demands NO_AUTH and must win. + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + const options = RequestOptions.newBuilder() + .auth(createAuthDescriptor([createAuthRequirement('NO_AUTH')])) + .build(); + + await runThrough(descriptor, transport, {options}); + + expect( + transport.calls[0]?.request.headers.get('X-Api-Key'), + ).toBeUndefined(); + }); + + test('a per-call descriptor that is unsatisfiable does NOT fall through to the client tier (AUTH-4)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const credentials: AuthCredentialSet = { + apiKey: {credential: new ApiKeyCredential('secret')}, + }; + const descriptor = authStep({credentials, tiers: tiersFor('API_KEY')}); + const options = RequestOptions.newBuilder() + .auth(createAuthDescriptor([createAuthRequirement('BASIC')])) + .build(); + + const error = await rejectionOf( + runThrough(descriptor, transport, {options}), + ); + + expect(error).toBeInstanceOf(AuthResolutionError); + expect(transport.sendCount).toBe(0); + }); +}); + +describe('authStep: answering an unrecognized scheme through challengeHook', () => { + // There is deliberately no `AuthStepSettings.handlers`: `challengeHook` is the ONE caller-facing + // extension point, and it covers the case a handler list was reaching for -- a scheme none of the + // built-in handlers recognizes -- without putting `ChallengeHandler` on the public barrel where + // neither `basicHandler` nor `digestHandler` is reachable to compose with. + test('a challengeHook answers a scheme no built-in handler recognizes', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Custom realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {basic: {username: 'u', password: 'p'}}, + tiers: tiersFor('BASIC'), + challengeHook: (_response, request) => + Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Custom abc') + .build(), + ) + .build(), + ), + }); + + await runThrough(descriptor, transport); + + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Custom abc', + ); + }); +}); + +describe('authStep: the call signal', () => { + test('the call signal reaches the challenge hook (AUTH-30)', async () => { + // A hook is the sanctioned place for a custom OAuth2 refresh grant, i.e. network I/O on the + // request path, so it must be able to observe the caller's cancellation. + const transport = new FakeTransport([ + challengeResponse(401, 'WWW-Authenticate', 'Basic realm="x"').response, + countingResponse(200).response, + ]); + const controller = new AbortController(); + let observed: AbortSignal | undefined; + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request, options) => { + observed = options?.signal; + return Promise.resolve(request); + }, + }); + const request = aRequest(); + + await new Cursor({ + steps: [descriptor], + transport, + request, + context: aRequestContext(request), + signal: controller.signal, + }).advance(); + + expect(observed).toBe(controller.signal); + }); + + // There is deliberately no "the provider is not given the call signal" test any more: after M6, + // `TokenProvider` is `() => Promise` and has no parameter to populate, so the property + // is structural. A test for it would only be exercising the type checker. +}); + +describe('authStep: a failing release never masks the primary error (RECOV-12)', () => { + test("a rejecting close() keeps the HOOK's own error primary (AUTH-32)", async () => { + // `Response.close()` rethrows whatever cancelling the body raised, so a bare + // `await response.close(); throw error;` discarded the hook's failure and surfaced the teardown + // failure in its place -- the inversion RECOV-12 forbids, and the one 5b's `decideOrClose` + // already guards against with the same two helpers. + const hookFailure = new Error('hook exploded'); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: () => Promise.reject(hookFailure), + }); + + const error = await rejectionOf( + runThrough(descriptor, new FakeTransport([hostileChallenge()])), + ); + + const suppressed = error as SuppressedErrorLike; + expect(suppressed.error).toBe(hookFailure); + expect(suppressed.suppressed).toBe(CANCEL_FAILURE); + }); + + test('a rejecting close() keeps PlaintextCredentialError primary on the replay guard (AUTH-28)', async () => { + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + // A replacement that downgrades to http AND carries a credential: AUTH-28 must refuse it, and + // that refusal is what the caller has to be able to see. + challengeHook: () => + Promise.resolve( + Request.newBuilder() + .url('http://example.com/a') + .headers( + Headers.newBuilder().set('Authorization', 'Bearer t').build(), + ) + .build(), + ), + }); + + const error = await rejectionOf( + runThrough(descriptor, new FakeTransport([hostileChallenge()])), + ); + + const suppressed = error as SuppressedErrorLike; + expect(suppressed.error).toBeInstanceOf(PlaintextCredentialError); + expect(suppressed.suppressed).toBe(CANCEL_FAILURE); + }); +}); + +describe('authStep: a non-replayable body still evicts (AUTH-31 vs AUTH-36)', () => { + test('a revoked token is evicted even though the replay is skipped', async () => { + // AUTH-31 gates the REPLAY on replayability; AUTH-36's eviction is a separate sentence. An + // earlier shape skipped the whole hook for a one-shot body, which left the token the server had + // just rejected sitting in the cache -- and a token with no `expiresAt` (AUTH-10's "never locally + // expires") never aged out either, so a stream-only client re-sent the dead credential forever. + let issued = 0; + const credentials: AuthCredentialSet = { + bearer: { + provider: () => { + issued += 1; + return Promise.resolve(createBearerToken(`t${String(issued)}`)); + }, + }, + }; + const descriptor = authStep({credentials, tiers: tiersFor('OAUTH2')}); + + const first = challengeResponse( + 401, + 'WWW-Authenticate', + 'Bearer realm="x"', + ); + const firstDrive = await runThrough( + descriptor, + new FakeTransport([first.response]), + { + request: oneShotPost(), + }, + ); + const second = challengeResponse( + 401, + 'WWW-Authenticate', + 'Bearer realm="x"', + ); + const secondTransport = new FakeTransport([second.response]); + await runThrough(descriptor, secondTransport, {request: oneShotPost()}); + + // AUTH-31 still holds: the original is surfaced unchanged and NOT closed. + expect(firstDrive.status.code).toBe(401); + expect(first.cancelCount()).toBe(0); + // AUTH-36 now also holds: the second request carries a freshly fetched token, not the dead one. + expect(secondTransport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer t2', + ); + }); +}); + +describe('authStep: refresh-margin validation (AUTH-34/AUTH-35)', () => { + // `nowMs + marginMs > expiresAt` is false for a NaN margin, so BOTH the margin check and AUTH-35's + // no-margin check say "not expired" and the cache serves a dead token from the hot path forever. + // Same rule and wording 5a's `retrySettings()` and 5b's `redirectSettings()` apply. + test('rejects a non-finite bearerMarginMs', () => { + expect(() => + authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + bearerMarginMs: Number.NaN, + }), + ).toThrow('finite, non-negative duration'); + }); + + test('rejects a negative bearerMarginMs', () => { + expect(() => + authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + bearerMarginMs: -1, + }), + ).toThrow('finite, non-negative duration'); + }); + + test('rejects a non-finite per-credential marginMs', () => { + expect(() => + authStep({ + credentials: { + bearer: { + provider: () => Promise.resolve(createBearerToken('t')), + marginMs: Number.NaN, + }, + }, + tiers: tiersFor('OAUTH2'), + }), + ).toThrow('finite, non-negative duration'); + }); +}); + +describe('authStep: the bearer refresh margin, in effect (AUTH-34/AUTH-37)', () => { + // The margin was validated at construction but its EFFECT was unasserted: both + // `AuthStepSettings.bearerMarginMs`'s 30 s default and `BearerCredential.marginMs`'s override could + // be deleted outright and every test still passed. AUTH-34 names the 30 s default itself, and + // `marginMs` is public surface, so both need a test that fails when the number changes. + // The two tests below pin the default from BOTH sides, deliberately. A single "a token 20 s out + // gets refreshed" assertion is satisfied by any margin >= 20 s, so it cannot tell 30 s from 60 s; + // the pair brackets the boundary at exactly 30 000 ms. + test("a token expiring just INSIDE AUTH-34's 30 s default is refreshed in the background", async () => { + const aging = agingTokenProvider(29_999); + const descriptor = authStep({ + credentials: aging.credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + // Drive 2 stamps the stale-but-valid t1 and kicks off the refresh; drive 3 sees t2 (AUTH-37). + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t2']); + expect(aging.callCount()).toBe(2); + }); + + test('a token expiring just OUTSIDE the 30 s default stays in the fresh zone', async () => { + const aging = agingTokenProvider(30_001); + const descriptor = authStep({ + credentials: aging.credentials, + tiers: tiersFor('OAUTH2'), + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t1']); + expect(aging.callCount()).toBe(1); + }); + + test('a per-credential marginMs overrides the step-wide one', async () => { + const aging = agingTokenProvider(29_999); + const bearer = aging.credentials.bearer; + invariant(bearer !== undefined, 'agingTokenProvider configures a bearer'); + const descriptor = authStep({ + credentials: {bearer: {...bearer, marginMs: 30_000}}, + tiers: tiersFor('OAUTH2'), + bearerMarginMs: 0, // the step-wide margin alone would leave t1 in the fresh zone forever + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t2']); + expect(aging.callCount()).toBe(2); + }); + + test('an explicit zero margin beats the default and suppresses the background refresh', async () => { + const aging = agingTokenProvider(29_999); // inside the default margin, outside a zero one + const descriptor = authStep({ + credentials: aging.credentials, + tiers: tiersFor('OAUTH2'), + bearerMarginMs: 0, + clock: {now: () => 0}, + }); + + const stamped = await stampsOverThreeDrives(descriptor); + + expect(stamped).toEqual(['Bearer t1', 'Bearer t1', 'Bearer t1']); + expect(aging.callCount()).toBe(1); + }); +}); + +describe('authStep: the replay HTTPS guard covers Proxy-Authorization too (AUTH-25/AUTH-28)', () => { + test('a replacement carrying only Proxy-Authorization over plaintext is refused', async () => { + // AUTH-28 says ANY path where a credential will be attached, and AUTH-25 makes + // `Proxy-Authorization` exactly such a path for a 407. The guard's `Authorization` arm was + // asserted and this one was not, so dropping it left a proxy credential able to go out over + // plaintext with the whole suite green. + const challenged = challengeResponse( + 407, + 'Proxy-Authenticate', + 'Basic realm="p"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), // outbound guard skipped entirely + challengeHook: (_response, request) => + Promise.resolve( + request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Proxy-Authorization', 'Basic c3B5') + .build(), + ) + .build(), + ), + }); + + const error = await rejectionOf( + runThrough(descriptor, transport, { + request: aRequest('http://example.com/a'), + }), + ); + + expect(error).toBeInstanceOf(PlaintextCredentialError); + expect(transport.sendCount).toBe(1); // the replacement never reached the wire + expect(challenged.cancelCount()).toBe(1); // and the 407 was closed before the throw + }); +}); + +describe('authStep: a challenge this client cannot echo (AUTH-21/AUTH-22)', () => { + test('a non-ASCII Digest realm surfaces the 401 unchanged rather than throwing', async () => { + // HTTP-19 lets a received field-value carry obs-text, so `realm="café"` -- a real RFC 7616 shape, + // which is why the spec has a `charset` parameter at all -- reaches us intact. HTTP-18 will not + // let it back out. `parseDigestChallenge` declines, so AUTH-33 surfaces the 401 open and + // unchanged; building the header anyway threw HeaderValidationError out of the whole step. + const challenge = challengeResponse( + 401, + 'WWW-Authenticate', + 'Digest realm="café", nonce="n", algorithm=MD5, charset=UTF-8', + ); + const transport = new FakeTransport([challenge.response]); + const descriptor = authStep({ + credentials: {digest: {username: 'u', password: 'p'}}, + tiers: tiersFor('DIGEST'), + }); + + const response = await runThrough(descriptor, transport); + + expect(response.status.code).toBe(401); + expect(transport.sendCount).toBe(1); // no replay + expect(challenge.cancelCount()).toBe(0); // AUTH-33: returned open, the caller's to close + }); +}); + +describe('authStep: cancellation (AUTH-30)', () => { + test('a call already aborted when the challenge arrives never runs the hook at all', async () => { + // The default OAUTH2 hook does an IdP round trip and the BASIC/DIGEST one does key derivation. + // Neither is worth doing for a caller who has already gone, so the hook is not even built. + let hookRan = false; + const controller = new AbortController(); + controller.abort(); + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request) => { + hookRan = true; + return Promise.resolve(request); + }, + }); + + const response = await runThrough(descriptor, transport, { + signal: controller.signal, + }); + + expect(hookRan).toBe(false); + expect(transport.sendCount).toBe(1); + expect(response.status.code).toBe(401); + expect(challenged.cancelCount()).toBe(0); // returned open, the caller's to close + }); + + test('an abort arriving DURING the hook still spends no second wire send', async () => { + // `redirectStep` checks `signal?.aborted` before each hop and the retry engine before each + // attempt; the auth step must not be the one pillar that dispatches for a caller who has gone. + const controller = new AbortController(); + const transport = new FakeTransport([ + challengeResponse(401, 'WWW-Authenticate', 'Basic realm="x"').response, + countingResponse(200).response, + ]); + const descriptor = authStep({ + credentials: {}, + tiers: tiersFor('NO_AUTH'), + challengeHook: (_response, request) => { + controller.abort(); // the caller gives up while the hook is running + return Promise.resolve(request); + }, + }); + + const response = await runThrough(descriptor, transport, { + signal: controller.signal, + }); + + expect(response.status.code).toBe(401); // surfaced open, like every other no-replay outcome + expect(transport.sendCount).toBe(1); + }); +}); diff --git a/packages/core/src/auth/auth-step.ts b/packages/core/src/auth/auth-step.ts new file mode 100644 index 0000000..8bb15c9 --- /dev/null +++ b/packages/core/src/auth/auth-step.ts @@ -0,0 +1,785 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/auth-step.ts +import type {Clock} from '../config/clock.js'; +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import {assertNever, invariant} from '../invariant.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {releaseQuietly, withReleaseFailure} from '../recovery/release.js'; +import { + clearCrossOriginMarker, + hasCrossOriginMarker, +} from '../redirect/cross-origin.js'; +import {basicHandler} from './basic.js'; +import {BearerTokenCache} from './bearer-cache.js'; +import {parseChallenges} from './challenge.js'; +import type {Challenge, ChallengeHandler} from './challenge.js'; +import {composingHandler, type ComposingHandler} from './composing-handler.js'; +import type { + ApiKeyCredential, + NameKeyCredential, + TokenProvider, +} from './credential.js'; +import type {AuthDescriptor} from './descriptor.js'; +import {digestHandler} from './digest.js'; +import type {DigestAlgorithm} from './digest.js'; +import {PlaintextCredentialError} from './errors.js'; +import {resolveAuthRequirement, type AuthTiers} from './resolve.js'; +import type {AuthScheme} from './scheme.js'; +import {stampStaticKey} from './static-key.js'; + +/** + * Username and password for the `BASIC` scheme. + * + * @public + */ +export interface BasicCredential { + /** The user id (AUTH-14: non-empty; whitespace permitted). */ + readonly username: string; + /** The password (AUTH-14: non-empty; whitespace permitted). */ + readonly password: string; +} + +/** + * Username, password, and algorithm preference for the `DIGEST` scheme. + * + * @public + */ +export interface DigestCredential { + /** The user id. Must not be blank. */ + readonly username: string; + /** The password. Must not be blank. */ + readonly password: string; + /** + * Preferred-first order, and also the acceptable set (AUTH-16). Omitted means strongest-first over + * all four supported algorithms. + */ + readonly algorithmPreference?: readonly DigestAlgorithm[] | undefined; +} + +/** + * The token source and refresh margin for the `OAUTH2` scheme. + * + * @public + */ +export interface BearerCredential { + /** The token source (AUTH-11). */ + readonly provider: TokenProvider; + /** Per-credential refresh margin; falls back to {@link AuthStepSettings.bearerMarginMs}. */ + readonly marginMs?: number | undefined; +} + +/** + * The static key, header, and prefix for the `API_KEY` scheme (AUTH-26). + * + * @public + */ +export interface ApiKeyCredentialConfig { + /** The key. Both credential classes are nominal, so no object literal substitutes for one. */ + readonly credential: ApiKeyCredential | NameKeyCredential; + /** The header to write. Defaults to `Authorization`. */ + readonly headerName?: string | undefined; + /** A scheme prefix, written followed by exactly one space. */ + readonly prefix?: string | undefined; +} + +/** + * Which schemes a caller has actually configured a credential for. + * + * This shape is designed by this phase — neither the product spec nor the design doc names one. It is + * both the credential material the step stamps with, and the source `availableSchemesOf()` derives + * AUTH-5's `availableSchemes` from, which is what keeps resolution from ever inspecting a concrete + * credential value. + * + * @public + */ +export interface AuthCredentialSet { + /** Enables the `BASIC` scheme. */ + readonly basic?: BasicCredential | undefined; + /** Enables the `DIGEST` scheme. */ + readonly digest?: DigestCredential | undefined; + /** Enables the `OAUTH2` scheme. */ + readonly bearer?: BearerCredential | undefined; + /** Enables the `API_KEY` scheme. */ + readonly apiKey?: ApiKeyCredentialConfig | undefined; +} + +/** + * AUTH-5: derives the satisfiable-scheme set from which credentials are configured, without exposing + * any credential value to resolution. + * + * `NO_AUTH` is deliberately absent: AUTH-5 makes it satisfiable unconditionally, so membership here + * would be redundant and would let a caller's empty credential set read as "nothing is available" + * while a `NO_AUTH` requirement still resolves. + * + * @param credentials - the configured credential set. + * @returns the schemes with a matching credential. + * + * @internal + */ +export function availableSchemesOf( + credentials: AuthCredentialSet, +): ReadonlySet { + const schemes = new Set(); + if (credentials.basic !== undefined) schemes.add('BASIC'); + if (credentials.digest !== undefined) schemes.add('DIGEST'); + if (credentials.bearer !== undefined) schemes.add('OAUTH2'); + if (credentials.apiKey !== undefined) schemes.add('API_KEY'); + return schemes; +} + +/** + * The handler list is derived from `credentials`, digest-first — "callers order stronger schemes + * first" (AUTH-23). Both handlers need a username and password to do anything, so a zero-argument + * `[digestHandler(), basicHandler()]` default is not constructible, which is why this is derived + * rather than defaulted. + * + * There is deliberately no caller override. An earlier shape took `AuthStepSettings.handlers`, which + * forced `Challenge`/`ChallengeHandler`/`DigestUriContext` onto the public barrel to make the field + * callable — and then delivered less than it promised: `basicHandler`/`digestHandler` stay internal, + * so a caller supplying one handler silently LOST the credential-derived ones rather than composing + * with them. {@link AuthStepSettings.challengeHook} already covers the custom-scheme case end to end, + * with a shape a caller can actually satisfy. + */ +function buildHandlers( + credentials: AuthCredentialSet, +): readonly ChallengeHandler[] { + const handlers: ChallengeHandler[] = []; + if (credentials.digest !== undefined) { + handlers.push( + digestHandler(credentials.digest.username, credentials.digest.password, { + algorithmPreference: credentials.digest.algorithmPreference, + }), + ); + } + if (credentials.basic !== undefined) { + handlers.push( + basicHandler(credentials.basic.username, credentials.basic.password), + ); + } + return handlers; +} + +/** + * AUTH-30's pluggable 401/407 reaction. + * + * Returning `undefined` means "no replacement" — the challenge response is surfaced unchanged. A + * returned request is driven exactly once through a fresh copy of the downstream chain, with no + * further challenge handling on that drive. + * + * @public + */ +export type ChallengeHook = ( + response: Response, + request: Request, + options?: { + /** + * The calling request's cancellation, threaded straight through from `StepContext.signal`. + * + * A hook is the sanctioned place to run a custom OAuth2 refresh-token grant, which is external + * I/O on the request path -- and `docs/knowledge/concurrency-and-async.md` is explicit that a + * signal accepted at the top of a call chain must reach the actual I/O primitive, or it is + * decoration. Without this a hung hook pinned the auth step, every retry attempt nested under + * it, and the whole request, with no way for the caller to abort. + */ + readonly signal?: AbortSignal | undefined; + }, +) => Promise; + +/** + * Everything {@link authStep} accepts. + * + * @public + */ +export interface AuthStepSettings { + /** Which schemes are available, and the material to stamp them with. */ + readonly credentials: AuthCredentialSet; + /** + * The operation and client tiers, fixed at construction. The `perCall` slot may additionally be + * supplied per call via `RequestOptions.auth` (AUTH-4), which wins over any `perCall` value + * configured here. + */ + readonly tiers: AuthTiers; + /** + * Replaces the scheme-dependent default 401/407 reaction entirely — e.g. a custom OAuth2 + * refresh-token grant (AUTH-30). + */ + readonly challengeHook?: ChallengeHook | undefined; + /** + * Refresh margin ahead of a bearer token's expiry. + * + * @defaultValue 30000 — AUTH-34's "default 30 seconds". + */ + readonly bearerMarginMs?: number | undefined; + /** + * Wall-clock source for bearer expiry evaluation, injected so the three-zone policy is testable + * through the step and not only through the cache directly. Reading `Date.now()` inside the cache + * would be a second, uncontrollable clock — `bearer-cache.ts` takes an injected `nowMs` precisely so + * its one caller can supply a controllable one, and this is that caller. + * + * Typed as the `now()` half of {@link Clock}, not a bare `() => number` and not the whole `Clock`: + * `RetryStepOptions.clock` is a full `Clock`, and one instance has to satisfy both slots or a + * caller who fakes time for retry and forgets auth gets two clocks disagreeing inside one pipeline. + * Narrowing to the member actually used means no caller has to implement `monotonic`/`sleep` for a + * step that never sleeps. + * + * @defaultValue a `now()` reading `Date.now()` + */ + readonly clock?: Pick | undefined; +} + +/** + * A refresh margin must be a finite, non-negative duration -- the same rule and the same wording + * 5a's `retrySettings()` and 5b's `redirectSettings()` apply to every numeric setting they take, and + * an invalid value is a PROGRAMMER error there and here alike, so it trips `invariant()` rather than + * a typed error leaf. + * + * Not decorative. `isBearerTokenExpired` is `nowMs + marginMs > expiresAt`, so a `NaN` margin -- the + * shape `Number(process.env.MARGIN_MS)` produces for an unset variable -- makes BOTH the margin + * comparison and AUTH-35's no-margin comparison false. The cache then reads a long-dead token as + * fresh, returns it from the hot path, and never calls the provider again: a revoked credential + * stamped onto every request, indefinitely and silently. A large negative margin does the same. + */ +function validateMarginMs(label: string, value: number | undefined): void { + if (value === undefined) return; + invariant( + Number.isFinite(value) && value >= 0, + `${label} must be a finite, non-negative duration, got ${String(value)}`, + ); +} + +/** AUTH-4: a per-call descriptor (`RequestOptions.auth`, via `StepContext.options`) fills the perCall slot. */ +function effectiveTiers( + configured: AuthTiers, + perCall: AuthDescriptor | undefined, +): AuthTiers { + return perCall === undefined ? configured : {...configured, perCall}; +} + +/** Stable identity for pillar-slot occupancy and anchor matching (PIPE-6/PIPE-18). @internal */ +export const AUTH_STEP_TYPE: unique symbol = Symbol('dexpace.auth'); + +/** AUTH-28: case-insensitive, evaluated before any token fetch or header write. */ +function requireHttps(url: URL, scheme: AuthScheme): void { + if (url.protocol.toLowerCase() !== 'https:') { + throw new PlaintextCredentialError('authStep', scheme); + } +} + +interface StampContext { + readonly scheme: AuthScheme; + readonly credentials: AuthCredentialSet; + readonly bearerCache: BearerTokenCache; + readonly marginMs: number; + readonly nowMs: number; + readonly signal: AbortSignal | undefined; +} + +function withHeader(request: Request, name: string, value: string): Request { + return request + .newBuilder() + .headers(request.headers.newBuilder().set(name, value).build()) + .build(); +} + +/** + * Whether the caller has given up. + * + * A function, not two inline `signal?.aborted === true` tests, and the indirection is load-bearing: + * `AbortSignal.aborted` is a LIVE getter that flips while an `await` is outstanding, but TypeScript + * narrows it like an ordinary property and carries that narrowing straight across the await. The + * second check in {@link handleChallenge} -- the one that exists precisely because the world moved + * during the hook -- therefore reads as `'false | undefined' and 'true' have no overlap` and fails to + * compile, which is the compiler being confidently wrong about mutable external state. Routing every + * read through a call re-reads the getter each time. + * + * `docs/knowledge/concurrency-and-async.md`: "state checked before an `await` must be re-validated + * after every `await` that could have let the world move." + */ +function isAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true; +} + +/** AUTH-25: which challenge header carried the offer decides which header the answer goes into. */ +function answerHeaderName(isProxy: boolean): string { + return isProxy ? 'Proxy-Authorization' : 'Authorization'; +} + +/** + * `OAUTH2` and `API_KEY` stamp preemptively — no server round-trip is needed to know what to send. + * `BASIC`, `DIGEST`, and `NO_AUTH` never do: Digest structurally cannot stamp before seeing the + * server's `realm`/`nonce`, AUTH-14/AUTH-23–AUTH-25 phrase Basic entirely in terms of answering a + * parsed challenge, and `NO_AUTH` has nothing to stamp. + * + * An exhaustive `switch` closing on `assertNever`, not an if-chain: `AuthScheme` is a closed + * discriminant, and `docs/knowledge/data-modeling.md` bars an if-chain over one because it gives no + * exhaustiveness guarantee and falls through silently when a variant is added — and the value that + * would fall through here is a credential-stamping decision. + */ +async function preemptiveStamp( + request: Request, + context: StampContext, +): Promise { + switch (context.scheme) { + case 'OAUTH2': { + const bearer = context.credentials.bearer; + invariant( + bearer !== undefined, + 'resolved OAUTH2 but no bearer credential configured', + ); + const token = await context.bearerCache.stamp({ + provider: bearer.provider, + marginMs: bearer.marginMs ?? context.marginMs, + nowMs: context.nowMs, + signal: context.signal, + }); + return withHeader(request, 'Authorization', `Bearer ${token.token}`); + } + case 'API_KEY': { + const apiKey = context.credentials.apiKey; + invariant( + apiKey !== undefined, + 'resolved API_KEY but no apiKey credential configured', + ); + const {headerName, headerValue} = stampStaticKey( + apiKey.credential, + apiKey, + ); + return withHeader(request, headerName, headerValue); + } + case 'BASIC': + case 'DIGEST': + case 'NO_AUTH': + return request; // challenge-driven, or no credential at all + default: + return assertNever(context.scheme); + } +} + +/** + * What the outbound pass decided, carried into the challenge pass. + * + * `crossOrigin` has to survive past the dispatch: AUTH-29's suppression covers the WHOLE hop, so the + * challenge reaction needs the same answer the outbound pass computed, and the marker itself is gone + * from the request by then. + */ +interface OutboundPlan { + readonly crossOrigin: boolean; + readonly outbound: Request; +} + +/** + * AUTH-29 then AUTH-28, in that order. + * + * The cross-origin check comes FIRST, and the marker is cleared unconditionally before either branch + * — it must never reach the wire, and clearing up front means it cannot survive into a request built + * by the stamping logic. A marked hop then skips the HTTPS guard AND preemptive stamping entirely, + * forwarding the cleared request credential-free; AUTH-29 makes that skip deliberate, so a + * server-chosen downgrade hop is forwarded rather than hard-failing. + * + * On an unmarked hop the HTTPS guard runs only where a credential will actually be attached — + * `NO_AUTH` is exempt, matching AUTH-28's own qualifier — and before any token fetch or header write. + */ +async function planOutbound( + seedRequest: Request, + context: StampContext, +): Promise { + const crossOrigin = hasCrossOriginMarker(seedRequest.headers); + const cleared = seedRequest + .newBuilder() + .headers(clearCrossOriginMarker(seedRequest.headers)) + .build(); + + if (crossOrigin) return {crossOrigin, outbound: cleared}; + if (context.scheme !== 'NO_AUTH') requireHttps(cleared.url, context.scheme); + return {crossOrigin, outbound: await preemptiveStamp(cleared, context)}; +} + +interface ChallengeSelection { + readonly value: string; + readonly isProxy: boolean; +} + +/** + * AUTH-25: a 401 is answered from `WWW-Authenticate`, a 407 from `Proxy-Authenticate`. Reading only + * the header that matches the status keeps the pairing honest — a 401 carrying a stray + * `Proxy-Authenticate` must not produce a `Proxy-Authorization`, and vice versa. + */ +function pickChallengeHeader( + response: Response, +): ChallengeSelection | undefined { + if (response.status.code === 401) { + const www = response.headers.get('WWW-Authenticate'); + return www === undefined ? undefined : {value: www, isProxy: false}; + } + const proxy = response.headers.get('Proxy-Authenticate'); + return proxy === undefined ? undefined : {value: proxy, isProxy: true}; +} + +interface DefaultHookContext { + readonly scheme: AuthScheme; + readonly credentials: AuthCredentialSet; + readonly bearerCache: BearerTokenCache; + readonly composing: ComposingHandler; + readonly marginMs: number; + readonly nowMs: number; + readonly signal: AbortSignal | undefined; +} + +/** AUTH-36: evict the exact rejected token, fetch a genuinely fresh one, re-stamp once. */ +async function oauth2ChallengeHook( + request: Request, + selection: ChallengeSelection, + context: DefaultHookContext, +): Promise { + const bearer = context.credentials.bearer; + invariant( + bearer !== undefined, + 'resolved OAUTH2 but no bearer credential configured', + ); + const headerName = answerHeaderName(selection.isProxy); + const rejected = request.headers.get(headerName); + // AUTH-36: no Authorization on the rejected request -> surface the challenge unchanged. + if (rejected === undefined) return undefined; + const challenges: readonly Challenge[] = parseChallenges(selection.value); + if (!challenges.some(challenge => challenge.scheme === 'bearer')) { + return undefined; // AUTH-36: the response advertises no Bearer challenge + } + + // AUTH-36's preservation clause, made observable: `evict()` clears the cache only when the cached + // token IS the rejected one, and hands back the survivor otherwise. A survivor means another + // request already refreshed past this 401, so the retry stamps THAT rather than burning a second + // provider fetch to arrive at the same place. + const preserved = context.bearerCache.evict(rejected); + if (preserved !== undefined) { + return withHeader(request, headerName, `Bearer ${preserved.token}`); + } + + // AUTH-37's post-eviction clause: a fetch that STARTED after this 401, so the retry cannot re-send + // the rejected token. Plain `stamp()` would coalesce onto a fetch that may have started before this + // 401 came back, and AUTH-11 permits a provider that caches internally, so that fetch can resolve + // to exactly the token the server just rejected. `refreshPostEviction` still coalesces concurrent + // 401s onto one fetch (AUTH-34) -- it supersedes pre-401 fetches only. + // + // The margin is INERT on this path and is passed anyway: `refresh()` validates the fetched token + // against a zero margin (AUTH-35) and never reads `BearerFetch.marginMs`, which only `stamp()` + // consults. It is resolved identically to the preemptive path rather than hard-coded, so the two + // call sites cannot drift apart if `refresh()` ever grows a margin-dependent branch -- and so a + // reader comparing them does not have to work out which of two spellings is the intended one. + const token = await context.bearerCache.refreshPostEviction({ + provider: bearer.provider, + marginMs: bearer.marginMs ?? context.marginMs, + nowMs: context.nowMs, + signal: context.signal, + }); + return withHeader(request, headerName, `Bearer ${token.token}`); +} + +/** + * AUTH-23–AUTH-25: delegate to the composing handler; no replacement when nothing is satisfiable. + * + * `selection.isProxy` reaches {@link answerHeaderName} and nothing else. The handlers produce the + * header VALUE only, and neither of them varies it by proxy-ness, so the flag stops here rather than + * being threaded into a contract that cannot use it. + */ +async function basicDigestChallengeHook( + request: Request, + selection: ChallengeSelection, + context: DefaultHookContext, +): Promise { + const challenges = parseChallenges(selection.value); + const url = request.url; // HTTP-5: a fresh URL per access, so read it once. + const requestTarget = `${url.pathname}${url.search}`; + const value = await context.composing.stamp(challenges, { + method: request.method, + requestTarget, + }); + if (value === undefined) return undefined; + return withHeader(request, answerHeaderName(selection.isProxy), value); +} + +/** + * The scheme-dependent default hook body. AUTH-30's generic contract governs INVOCATION; this decides + * what each resolved scheme does with a parsed challenge. `API_KEY`/`NO_AUTH` never react — static or + * absent credentials have no reactive behavior, which is exactly AUTH-30's "the default hook yields no + * replacement". + * + * Exhaustive `switch` + `assertNever`, not an if-chain, for the same reason as `preemptiveStamp`: a + * sixth `AuthScheme` added later must not silently inherit the BASIC/DIGEST branch's stamping. + */ +async function defaultChallengeHook( + response: Response, + request: Request, + context: DefaultHookContext, +): Promise { + const selection = pickChallengeHeader(response); + if (selection === undefined) return undefined; + + switch (context.scheme) { + case 'OAUTH2': + return oauth2ChallengeHook(request, selection, context); + case 'BASIC': + case 'DIGEST': + return basicDigestChallengeHook(request, selection, context); + case 'API_KEY': + case 'NO_AUTH': + return undefined; + default: + return assertNever(context.scheme); + } +} + +/** + * AUTH-28 on the REPLAY path. The outbound guard is not sufficient here: it is skipped entirely for + * `NO_AUTH`, and nothing constrains a caller-supplied hook to preserve the request URL. A replay + * carrying a credential header is by definition "a path where a credential will be attached", and + * AUTH-28 says ANY such path. + * + * The challenge response is closed before the throw, for the same reason AUTH-32 closes it on a hook + * throw: this is past the point where the caller still owns it, so propagating unclosed leaks the body. + */ +async function guardReplayScheme( + replacement: Request, + response: Response, + scheme: AuthScheme, +): Promise { + const carriesCredential = + replacement.headers.has('Authorization') || + replacement.headers.has('Proxy-Authorization'); + if (!carriesCredential) return; + try { + requireHttps(replacement.url, scheme); + } catch (error) { + // The GUARD's error stays primary. `Response.close()` rethrows whatever cancelling the body + // raised, so a bare `await response.close()` here replaced `PlaintextCredentialError` -- typed, + // caller-catchable, security-relevant -- with the teardown failure, the inversion RECOV-12 + // forbids. Same helpers 4b built and 5b's `decideOrClose` uses. + throw withReleaseFailure(error, await releaseQuietly(response)); + } +} + +/** What {@link runHook} needs besides the hook itself. Bundled to stay inside `max-params`. */ +interface HookInvocation { + readonly response: Response; + readonly request: Request; + readonly signal: AbortSignal | undefined; +} + +/** + * AUTH-32: a hook that throws, or whose promise rejects, closes the open challenge response before the + * error propagates. + * + * The HOOK's error stays primary. `Response.close()` rethrows whatever cancelling the body raised, so + * a bare `await response.close()` here discarded the hook's own failure and surfaced the teardown + * failure in its place -- RECOV-12's "attaching any close error as suppressed so it never masks the + * primary", inverted. `releaseQuietly`/`withReleaseFailure` are 4b's helpers, shared with the retry + * engine and 5b's `decideOrClose`. + */ +async function runHook( + hook: ChallengeHook, + invocation: HookInvocation, +): Promise { + const {response, request, signal} = invocation; + try { + return await hook(response, request, {signal}); + } catch (error) { + throw withReleaseFailure(error, await releaseQuietly(response)); + } +} + +interface ChallengeDrive { + readonly response: Response; + readonly outbound: Request; + readonly fork: () => (request?: Request) => Promise; + readonly settings: AuthStepSettings; + readonly hookContext: DefaultHookContext; +} + +/** + * AUTH-30–AUTH-33: the 401/407 reaction, split out of the pillar closure to keep both under the + * 70-line cap and to give the response-lifecycle rules one place to live. + * + * The challenge response is returned OPEN — the caller's to close — on every no-replay outcome (no + * matching challenge header, a one-shot body, a hook yielding nothing, a non-replayable replacement). + * It is CLOSED before the replay dispatch, and before propagating a hook throw or a replay-path guard + * failure. + */ +async function handleChallenge(drive: ChallengeDrive): Promise { + const {response, outbound, fork, settings, hookContext} = drive; + + const selection = pickChallengeHeader(response); + // AUTH-33: no matching challenge header -> unchanged, and the hook is never consulted. + if (selection === undefined) return response; + + // There is deliberately NO "skip the hook when the body is one-shot" fast path here. An earlier + // shape had one, on the reasoning that the default hook would only fetch a replacement that is + // then thrown away -- which is wrong on inspection: OAUTH2's hook EVICTS the rejected token and + // populates the cache for every subsequent request, so the work is not wasted. Skipping it left a + // server-revoked token cached behind every non-replayable request, and a token with no `expiresAt` + // (AUTH-10's "never locally expires") never aged out either, so a stream-only client re-sent the + // dead credential forever. AUTH-36's eviction clause and AUTH-31's replay gate are separate + // sentences; only the DISPATCH below is gated. + + // The caller had already abandoned this call before the challenge even arrived. The default OAUTH2 + // hook does an IdP round trip and the BASIC/DIGEST one does key derivation, so running either here + // is pure waste -- and `redirectStep` makes the same call, returning the current response open + // rather than doing more work. The signal threaded into `runHook` below covers the other case: an + // abort arriving while a hook is already in flight. + if (isAborted(hookContext.signal)) return response; + + const hook: ChallengeHook = + settings.challengeHook ?? + ((res, req) => defaultChallengeHook(res, req, hookContext)); + + const replacement = await runHook(hook, { + response, + request: outbound, + signal: hookContext.signal, + }); + // AUTH-33: the hook yielded nothing -> the challenge response is surfaced unchanged and unclosed. + if (replacement === undefined) return response; + + // AUTH-31, applied uniformly: a non-replayable replacement body skips the replay, surfaces the + // original unchanged, and MUST NOT close it -- the caller owns it. The reference applies this gate on + // its sync step only and recommends a port extend it; with one unified step there is exactly one + // place to apply it, so it covers OAUTH2's evict-and-retry too. + if (replacement.body !== undefined && !replacement.body.replayable) { + return response; + } + + // The caller gave up WHILE the hook ran -- the pre-hook check above cannot see this one. Surfacing + // the challenge open and unclosed is the same answer every other no-replay outcome gives; spending + // a second wire send on a request nobody is waiting for is the one thing that must not happen. + if (isAborted(hookContext.signal)) return response; + + await guardReplayScheme(replacement, response, hookContext.scheme); // AUTH-28 + + await response.close(); // AUTH-30: the original is closed before the replacement is driven. + // AUTH-30: exactly once, through a FRESH chain copy, with no further challenge handling on it. + return fork()(replacement); +} + +/** + * The single AUTH pillar step (AUTH-27–AUTH-33). + * + * One pluggable challenge-reaction extension point ({@link AuthStepSettings.challengeHook}) with a + * scheme-dependent default body — not three competing mechanisms. AUTH-30's contract (consult the + * hook, close the original on a non-null replacement, re-drive once through a fresh chain copy, no + * nested re-challenge) governs every scheme uniformly; AUTH-23–AUTH-26 and AUTH-34–AUTH-37 describe + * what the DEFAULT hook does for each resolved scheme. + * + * `stage: 'AUTH'` is baked into the descriptor this factory returns, which is how PIPE-36 is satisfied + * structurally. `ctx.fork` is asserted rather than checked — AUTH is in `PILLAR_STAGES`, so its + * absence means the descriptor was installed somewhere it cannot be, a programmer error. Every + * dispatch, INCLUDING the first, goes through a fresh `ctx.fork()` rather than `ctx.next()`, since a + * challenge may drive the chain a second time and `next()`'s single-invocation guard would trip + * (PIPE-15). + * + * Nested inside both redirect (5b) and retry (5a) per AUTH-27's "redirect wraps retry wraps auth", so + * it re-resolves and re-stamps per redirect hop and per retry attempt (PIPE-2). + * + * Both challenge statuses are handled: a 401 is answered from `WWW-Authenticate` into `Authorization`, + * a 407 from `Proxy-Authenticate` into `Proxy-Authorization` (AUTH-25). A cross-origin-marked hop + * answers neither (AUTH-29). + * + * AUTH-38 is satisfied structurally: `fn` is `async`, so the HTTPS-guard failure and any hook error + * reach the caller as a rejected promise rather than a synchronous throw. + * + * @param settings - credentials, tiers, and the optional challenge hook and clock overrides. + * @returns the descriptor to install in a pipeline's AUTH slot. + * @throws PlaintextCredentialError — as a rejected promise — when the resolved scheme would attach a + * credential over a non-HTTPS URL (AUTH-28), on the outbound pass and again on a challenge replay. + * Recover by fixing the endpoint's scheme; retrying will not help. + * @throws AuthResolutionError — as a rejected promise — when the selected tier lists no scheme with a + * matching configured credential (AUTH-6; AUTH-4 governs only WHICH tier is selected), or when the + * token provider returns a null or already-expired token (AUTH-35). The first is a configuration + * fault; the second is transient and the next request retries the fetch. + * @throws HeaderValidationError — as a rejected promise — when the credential material will not fit in + * a header value: a `TokenProvider` yielding a token with a control character passes AUTH-9's + * non-blank check but fails HTTP-18's outbound grammar at the write. + * @throws InvariantViolation — synchronously from this factory when `bearerMarginMs` or + * `BearerCredential.marginMs` is not a finite, non-negative duration, or a configured Digest/Basic + * credential is blank or not header-safe; and as a rejected promise from `send()` when no auth tier + * is configured at all (AUTH-6). All are caller misconfigurations, not operational failures. + * @throws Anything a caller-supplied `TokenProvider` or `challengeHook` raises, unwrapped and + * unconverted — the same pass-through stance the redirect step takes for its `predicate`. + * + * @example + * ```ts + * const runtime = new PipelineBuilder(transport) + * .append(authStep({ + * credentials: {bearer: {provider: () => fetchToken({signal: AbortSignal.timeout(5_000)})}}, + * tiers: {client: createAuthDescriptor([createAuthRequirement('OAUTH2')])}, + * })) + * .build(); + * ``` + * + * @public + */ +export function authStep(settings: AuthStepSettings): StepDescriptor { + // Built ONCE per installed step, not per request. The bearer cache is the one piece of shared + // mutable state, and sharing it across calls is the point -- AUTH-34's single-flight coalescing only + // works if concurrent calls meet at the same instance. + const bearerCache = new BearerTokenCache(); + const availableSchemes = availableSchemesOf(settings.credentials); + const composing = composingHandler(buildHandlers(settings.credentials)); + validateMarginMs('authStep bearerMarginMs', settings.bearerMarginMs); + validateMarginMs( + 'BearerCredential marginMs', + settings.credentials.bearer?.marginMs, + ); + const bearerMarginMs = settings.bearerMarginMs ?? 30_000; + const readNow = settings.clock?.now.bind(settings.clock) ?? Date.now; + + return { + type: AUTH_STEP_TYPE, + stage: 'AUTH', + fn: async (seedRequest, ctx) => { + const {fork, signal} = ctx; + invariant( + fork !== undefined, + 'authStep must occupy the AUTH pillar stage', + ); + + const {scheme} = resolveAuthRequirement( + effectiveTiers(settings.tiers, ctx.options?.auth), + availableSchemes, + ); + // One clock read per hop, threaded into every expiry evaluation this hop performs, so the + // preemptive stamp and a challenge-driven refresh cannot disagree about "now" mid-call. + const nowMs = readNow(); + const stampContext: StampContext = { + scheme, + credentials: settings.credentials, + bearerCache, + marginMs: bearerMarginMs, + nowMs, + signal, + }; + + const {crossOrigin, outbound} = await planOutbound( + seedRequest, + stampContext, + ); + + const response = await fork()(outbound); + const status = response.status.code; + if (status !== 401 && status !== 407) return response; + + // AUTH-29, second half: the marker suppresses stamping for the WHOLE hop, not just the outbound + // pass. Answering a challenge here would stamp exactly the credential `planOutbound` declined + // to send -- onto the server-chosen foreign host, over a URL whose HTTPS guard was deliberately + // skipped. The challenge is the caller's to handle, so the response is returned untouched and + // unclosed. + if (crossOrigin) return response; + + return handleChallenge({ + response, + outbound, + fork, + settings, + hookContext: {...stampContext, composing}, + }); + }, + }; +} diff --git a/packages/core/src/auth/basic.test.ts b/packages/core/src/auth/basic.test.ts new file mode 100644 index 0000000..d2c9512 --- /dev/null +++ b/packages/core/src/auth/basic.test.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/basic.test.ts +// Exercises: AUTH-14 ('Basic ' + base64(UTF-8(username:password)), computed once; accepts a basic +// challenge case-insensitively; whitespace-only credentials are PERMITTED -- RFC 7617's laxer rule, +// deliberately different from the credential types' stricter non-blank check in credential.ts), +// AUTH-25 (the handler returns the header VALUE only, never picks the header name). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import {basicHandler} from './basic.js'; + +const basicChallenge = {scheme: 'basic', params: new Map()}; + +describe('basicHandler', () => { + test('produces "Basic " + base64(UTF-8(username:password))', async () => { + const handler = basicHandler('Aladdin', 'open sesame'); + const value = await handler.stamp(basicChallenge); + expect(value).toBe('Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=='); + }); + + test('handles non-ASCII credentials via UTF-8 encoding', async () => { + const handler = basicHandler('üser', 'päss'); + const value = await handler.stamp(basicChallenge); + expect(value.startsWith('Basic ')).toBe(true); + // A naive Latin-1 btoa would produce a different, wrong encoding. + expect(value).toBe( + `Basic ${btoa( + String.fromCharCode(...new TextEncoder().encode('üser:päss')), + )}`, + ); + }); + + test('canHandle accepts "basic" (parseChallenges already lower-cases the scheme)', () => { + const handler = basicHandler('u', 'p'); + expect(handler.canHandle(basicChallenge)).toBe(true); + expect(handler.canHandle({scheme: 'digest', params: new Map()})).toBe( + false, + ); + }); + + test('whitespace-only credentials are permitted (RFC 7617, laxer than credential.ts)', () => { + expect(() => basicHandler(' ', ' ')).not.toThrow(); + }); + + test('a truly empty username or password is rejected', () => { + expect(() => basicHandler('', 'p')).toThrow(InvariantViolation); + expect(() => basicHandler('u', '')).toThrow(InvariantViolation); + }); + + test('the encoded value is computed once, at construction', async () => { + const handler = basicHandler('u', 'p'); + const first = await handler.stamp(basicChallenge); + const second = await handler.stamp(basicChallenge); + expect(first).toBe(second); + }); + + test('declares no rank -- it has no algorithm variants to prefer among (AUTH-16)', () => { + // `'rank' in handler`, not `handler.rank` -- reading an unbound method off an object literal + // trips `@typescript-eslint/unbound-method`, and presence is what the assertion is about anyway. + expect('rank' in basicHandler('u', 'p')).toBe(false); + }); +}); diff --git a/packages/core/src/auth/basic.ts b/packages/core/src/auth/basic.ts new file mode 100644 index 0000000..c16d949 --- /dev/null +++ b/packages/core/src/auth/basic.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/basic.ts +import {invariant} from '../invariant.js'; +import type {Challenge, ChallengeHandler} from './challenge.js'; + +/** + * `btoa` is Latin-1: it throws on any code point above U+00FF and mis-encodes the rest. Encoding to + * UTF-8 bytes first and handing `btoa` one character per byte is what makes a non-ASCII password + * base64 to the bytes RFC 7617 specifies. `globalThis.btoa` is used rather than `node:buffer` to keep + * the package portable (SEAM-1, `sdk-design-nodejs/06`). + */ +function toBase64Utf8(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** + * The Basic challenge handler (AUTH-14). + * + * The header value is `Basic ` plus base64 of the UTF-8 encoding of `username:password`, computed + * ONCE at construction and closed over — "computed once" is AUTH-14's own wording, not a performance + * nicety. + * + * Credentials are validated as non-empty but whitespace IS permitted, per RFC 7617's laxer rule. + * This is deliberately NOT the stricter `.trim().length > 0` check `credential.ts`'s types apply: a + * caller intentionally using a whitespace-only password is unusual but RFC 7617-legal, and rejecting + * it here would be this port inventing a restriction the requirement declines to make. + * + * Challenge-reactive only, never preemptive: `authStep()` engages this handler on a 401/407, never on + * the outbound pass. See `auth-step.ts` for that reading of AUTH-14/AUTH-23–AUTH-25. + * + * @param username - the user id. Must be non-empty; whitespace permitted. + * @param password - the password. Must be non-empty; whitespace permitted. + * @returns a stateless handler that answers `basic` challenges. + * @throws InvariantViolation when either credential is empty — a caller misconfiguration. + * + * @internal + */ +export function basicHandler( + username: string, + password: string, +): ChallengeHandler { + invariant(username.length > 0, 'Basic username must not be empty'); + invariant(password.length > 0, 'Basic password must not be empty'); + const value = `Basic ${toBase64Utf8(`${username}:${password}`)}`; + + return { + // `challenge.scheme` arrives lower-cased from `parseChallenges`, which is where AUTH-14's + // case-insensitivity is actually implemented. + canHandle: (challenge: Challenge): boolean => challenge.scheme === 'basic', + // Zero parameters, and that is the whole contract: the value was computed at construction, so + // neither the challenge nor the request-target can change it. AUTH-25's + // Authorization/Proxy-Authorization choice is the caller's, made from which challenge header the + // status carried. + stamp: (): Promise => Promise.resolve(value), + }; +} diff --git a/packages/core/src/auth/bearer-cache.test.ts b/packages/core/src/auth/bearer-cache.test.ts new file mode 100644 index 0000000..23b1654 --- /dev/null +++ b/packages/core/src/auth/bearer-cache.test.ts @@ -0,0 +1,643 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/bearer-cache.test.ts +// Exercises: AUTH-34 (fresh-zone hot-path read, no refresh), AUTH-35 (null/expired provider result +// throws and is never cached; a rejecting provider propagates and is never cached), AUTH-37 +// (expiring-but-valid zone: stale value returned, background refresh fired, a FAILED background +// refresh non-fatal and not an unhandled rejection; expired/missing zone: single-flight await, +// concurrent callers coalesce to exactly one provider invocation; the post-eviction path +// (`refreshPostEviction`) fetches genuinely fresh, while concurrent post-eviction refreshes still +// coalesce onto ONE fetch), AUTH-36 +// (eviction matched on the stamped header value; the survivor is returned so the preservation clause +// is observable), AUTH-11 (a provider error propagates through the async channel and is never +// cached), AUTH-38 (a provider that fails SYNCHRONOUSLY still reaches the async channel, so a +// background refresh stays non-fatal and refreshPostEviction never throws synchronously), AUTH-34's +// cancellation shape (the shared fetch carries no caller signal; each caller races its own, and a +// long-lived signal reused across many fetches does not accumulate abort listeners). +// +// Every `nowMs` below is injected, and the cache validates fetched tokens against that SAME injected +// clock -- so `expiresAt` values are small synthetic epochs, not wall-clock instants. A cache that +// reached for `Date.now()` internally would reject every one of these tokens. +import {describe, expect, test} from 'bun:test'; +import {BearerTokenCache, type BearerFetch} from './bearer-cache.js'; +import { + createBearerToken, + type BearerToken, + type TokenProvider, +} from './credential.js'; +import {AuthResolutionError} from './errors.js'; + +function providerReturning(token: ReturnType): { + provider: TokenProvider; + callCount: () => number; +} { + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return Promise.resolve(token); + }; + return {provider, callCount: () => invocations}; +} + +/** Fails the test if invoked. Used to assert a path did NOT reach the provider. */ +function unexpectedProvider(why: string): TokenProvider { + return () => Promise.reject(new Error(`provider must not be called: ${why}`)); +} + +/** The four fetch parameters are bundled (`BearerFetch`); this keeps the call sites readable. */ +function fetchWith( + provider: TokenProvider, + marginMs: number, + nowMs: number, +): BearerFetch { + return {provider, marginMs, nowMs, signal: undefined}; +} + +/** + * A macrotask boundary -- not a fixed number of microtask hops -- so a fire-and-forget refresh's whole + * then/finally chain has drained regardless of how many ticks it takes. + */ +function drainMacrotask(): Promise { + return new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this runner's + * type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper keeps the + * assertion honest without a lint suppression. Same shape 5a's and 5b's step suites settled on. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('BearerTokenCache: the fresh and expiring zones (AUTH-34/AUTH-37)', () => { + test('a fresh cached token is returned without invoking the provider (AUTH-34)', async () => { + const cache = new BearerTokenCache(); + const fresh = providerReturning(createBearerToken('t1', 10_000)); + await cache.stamp(fetchWith(fresh.provider, 1000, 0)); // primes the cache + // nowMs=0, expiresAt=10000, margin=1000 -- not expiring. + const result = await cache.stamp( + fetchWith(unexpectedProvider('the cached token is still fresh'), 1000, 0), + ); + expect(result.token).toBe('t1'); + expect(fresh.callCount()).toBe(1); + }); + + test('a token with no expiry is always in the fresh zone (AUTH-10/AUTH-34)', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith(providerReturning(createBearerToken('forever')).provider, 0, 0), + ); + const result = await cache.stamp( + fetchWith( + unexpectedProvider('a token with no expiry never expires locally'), + 60_000, + Number.MAX_SAFE_INTEGER, + ), + ); + expect(result.token).toBe('forever'); + }); + + test('expiring-but-valid: returns the stale token AND fires a background refresh (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + const initial = providerReturning(createBearerToken('t1', 1000)); + // primes: expiresAt=1000, nowMs=0, margin=500 -- not yet expiring + await cache.stamp(fetchWith(initial.provider, 500, 0)); + + const refreshed = providerReturning(createBearerToken('t2', 5000)); + // nowMs=900: expiring (900+500 > 1000) but not expired (900 > 1000 is false) + const result = await cache.stamp(fetchWith(refreshed.provider, 500, 900)); + expect(result.token).toBe('t1'); // stale value returned immediately + await drainMacrotask(); + const after = await cache.stamp( + fetchWith(unexpectedProvider('the refresh already cached t2'), 500, 900), + ); + expect(after.token).toBe('t2'); + }); +}); + +describe('BearerTokenCache: the expired/missing zone (AUTH-37)', () => { + test('expired/missing: awaits a fresh fetch', async () => { + const cache = new BearerTokenCache(); + // The FETCHED token must itself be valid at the injected `nowMs` -- a provider handing back an + // already-expired token is AUTH-35's rejection case, covered separately below. + const {provider, callCount} = providerReturning( + createBearerToken('t1', 10_000), + ); + const result = await cache.stamp(fetchWith(provider, 0, 5000)); // nothing cached + expect(result.token).toBe('t1'); + expect(callCount()).toBe(1); + }); + + test('an EXPIRED cached token awaits a fresh fetch rather than being stamped (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 1000)).provider, + 0, + 0, + ), + ); + const {provider, callCount} = providerReturning( + createBearerToken('t2', 9000), + ); + const result = await cache.stamp(fetchWith(provider, 0, 5000)); // t1 expired at 1000 + expect(result.token).toBe('t2'); + expect(callCount()).toBe(1); + }); +}); + +describe('BearerTokenCache: a failed background refresh is non-fatal (AUTH-37)', () => { + test('a FAILING background refresh is non-fatal and never becomes an unhandled rejection (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 1000)).provider, + 500, + 0, + ), + ); + + const failing: TokenProvider = () => + Promise.reject(new Error('refresh backend down')); + // Expiring-but-valid: stamps t1, refresh fails in the background. + const result = await cache.stamp(fetchWith(failing, 500, 900)); + // The still-valid token was already stamped -- the failure changes nothing. + expect(result.token).toBe('t1'); + + // Drain past the fire-and-forget chain; an unhandled rejection would surface here. + await drainMacrotask(); + + // t1 is still cached and still served -- a failed refresh must not evict what it failed to replace. + const after = await cache.stamp( + fetchWith( + unexpectedProvider('t1 is still cached and still valid at this nowMs'), + 0, + 900, + ), + ); + expect(after.token).toBe('t1'); + }); +}); + +describe('BearerTokenCache: single-flight and cancellation (AUTH-11/AUTH-34)', () => { + test('concurrent expired/missing callers coalesce to exactly one provider invocation (single-flight)', async () => { + let resolveProvider: + ((token: ReturnType) => void) | undefined; + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return new Promise(resolve => { + resolveProvider = resolve; + }); + }; + const cache = new BearerTokenCache(); + + const first = cache.stamp(fetchWith(provider, 0, 0)); + const second = cache.stamp(fetchWith(provider, 0, 0)); + expect(invocations).toBe(1); // the second caller coalesced onto the first's in-flight fetch + + resolveProvider?.(createBearerToken('t1', 10_000)); + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult.token).toBe('t1'); + expect(secondResult.token).toBe('t1'); + }); +}); + +describe('BearerTokenCache: cancellation is per-caller, not per-fetch (AUTH-34)', () => { + // A coalesced fetch is owned by no single call, so it carries no caller signal. That is structural + // rather than asserted: `TokenProvider` is `() => Promise` and has no parameter to + // populate. What IS asserted below is the behaviour that replaces it -- each caller races its own + // wait against its own signal. + test("an aborting caller stops waiting without cancelling a coalesced caller's fetch", async () => { + let resolveProvider: + ((token: ReturnType) => void) | undefined; + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return new Promise(resolve => { + resolveProvider = resolve; + }); + }; + const cache = new BearerTokenCache(); + const controller = new AbortController(); + + const aborting = cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + const patient = cache.stamp(fetchWith(provider, 0, 0)); // no signal at all + expect(invocations).toBe(1); + + controller.abort(new Error('caller A gave up')); + expect((await rejectionOf(aborting)) as Error).toHaveProperty( + 'message', + 'caller A gave up', + ); + + // The shared fetch was never cancelled, so B still gets its token. + resolveProvider?.(createBearerToken('t1', 10_000)); + expect((await patient).token).toBe('t1'); + expect(invocations).toBe(1); + }); + + test('a caller whose signal is already aborted rejects without starting a fetch', async () => { + const cache = new BearerTokenCache(); + const controller = new AbortController(); + controller.abort(new Error('already gone')); + + const rejected = await rejectionOf( + cache.stamp({ + provider: unexpectedProvider('the caller had already aborted'), + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }), + ); + + expect(rejected as Error).toHaveProperty('message', 'already gone'); + }); +}); + +describe('BearerTokenCache: a signal outliving many fetches (AUTH-34)', () => { + test('a long-lived signal driving many sequential fetches still aborts exactly one waiter', async () => { + // `raceAbort` attaches an abort listener per WAIT and removes it in a `finally`. Without that + // removal a caller signal that outlives many token fetches -- one request driving a long + // paginated sweep, say -- accumulates one dead listener per fetch until Node's + // MaxListenersExceededWarning fires. The listener COUNT is asserted directly in + // `test/node-conformance/auth.test.mjs`, where `node:events`' `getEventListeners` is available; + // this is the behavioural half, on Bun: after many settled fetches the signal must still drive + // exactly the one waiter outstanding when it fires, not a backlog of stale ones. + const cache = new BearerTokenCache(); + const controller = new AbortController(); + let rejections = 0; + for (let round = 0; round < 8; round += 1) { + const {provider} = providerReturning( + createBearerToken(`t${String(round)}`, 10_000), + ); + await cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + cache.evict(`Bearer t${String(round)}`); // force the next round back into the fetch path + } + + let release: ((token: BearerToken) => void) | undefined; + const parked = cache.stamp({ + provider: () => + new Promise(resolve => { + release = resolve; + }), + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + controller.abort(new Error('the sweep was cancelled')); + if ((await rejectionOf(parked)) !== undefined) rejections += 1; + + expect(rejections).toBe(1); + release?.(createBearerToken('unused', 10_000)); + }); +}); + +describe('BearerTokenCache: a provider that fails SYNCHRONOUSLY (AUTH-37/AUTH-38)', () => { + // `TokenProvider` is caller-supplied and its declared return type is a promise, but a plain-JS + // provider can throw before returning one -- the same boundary AUTH-35's `null` guard distrusts. + const syncThrowing: TokenProvider = () => { + throw new Error('provider exploded synchronously'); + }; + + test('a failed BACKGROUND refresh stays non-fatal: the valid token is still stamped (AUTH-37)', async () => { + const cache = new BearerTokenCache(); + const {provider} = providerReturning(createBearerToken('t1', 1000)); + await cache.stamp(fetchWith(provider, 0, 0)); // primes the cache + + // nowMs=900, margin=200 -> expiring-but-valid. AUTH-37: stamp the stale token, refresh in the + // background, and the background failure MUST NOT fail this request. A bare `provider()` call + // threw straight out of `stamp` here, past the `void ... .catch(...)` that had not been attached + // yet, rejecting a request that had a perfectly good token to send. + const stamped = await cache.stamp(fetchWith(syncThrowing, 200, 900)); + + expect(stamped.token).toBe('t1'); + }); + + test('refreshPostEviction rejects rather than throwing synchronously (AUTH-38)', async () => { + const cache = new BearerTokenCache(); + + const rejected = await rejectionOf( + cache.refreshPostEviction(fetchWith(syncThrowing, 0, 0)), + ); + + expect(rejected as Error).toHaveProperty( + 'message', + 'provider exploded synchronously', + ); + }); + + test('stamp rejects rather than throwing synchronously on the expired/missing path', async () => { + const cache = new BearerTokenCache(); + + const rejected = await rejectionOf( + cache.stamp(fetchWith(syncThrowing, 0, 0)), + ); + + expect(rejected as Error).toHaveProperty( + 'message', + 'provider exploded synchronously', + ); + }); +}); + +describe('BearerTokenCache: provider failure handling (AUTH-11/AUTH-35)', () => { + test('a null provider result throws AuthResolutionError (AUTH-35)', async () => { + const cache = new BearerTokenCache(); + // A plain-JS caller can hand back null regardless of TokenProvider's non-nullable return type; + // AUTH-35 requires a RUNTIME guard, so the cast is the point of the test, not a workaround. + const nullish = (() => Promise.resolve(null)) as unknown as TokenProvider; + expect( + await rejectionOf(cache.stamp(fetchWith(nullish, 0, 0))), + ).toBeInstanceOf(AuthResolutionError); + }); + + test('an already-expired provider result throws and is never cached (AUTH-35)', async () => { + const cache = new BearerTokenCache(); + const alreadyExpired: TokenProvider = () => + Promise.resolve(createBearerToken('t1', -1)); // expiresAt in the past + expect( + await rejectionOf(cache.stamp(fetchWith(alreadyExpired, 0, 1000))), + ).toBeInstanceOf(AuthResolutionError); + + const {provider: recovers, callCount} = providerReturning( + createBearerToken('t2', 10_000), + ); + const result = await cache.stamp(fetchWith(recovers, 0, 1000)); + expect(result.token).toBe('t2'); + // The earlier rejection left nothing cached to short-circuit this call. + expect(callCount()).toBe(1); + }); + + test('a rejecting provider propagates and is never cached (AUTH-11)', async () => { + const cache = new BearerTokenCache(); + const boom = new Error('network down'); + const failing: TokenProvider = () => Promise.reject(boom); + expect(await rejectionOf(cache.stamp(fetchWith(failing, 0, 0)))).toBe(boom); + + const {provider: recovers} = providerReturning( + createBearerToken('t1', 10_000), + ); + const result = await cache.stamp(fetchWith(recovers, 0, 0)); + expect(result.token).toBe('t1'); // no stale rejection cached -- this call fetches cleanly + }); +}); + +describe('BearerTokenCache: refreshPostEviction supersedes a pre-401 fetch (AUTH-37)', () => { + test('does NOT coalesce onto a fetch that was already in flight', async () => { + // The exact hazard: a background refresh started BEFORE the 401 came back. AUTH-11 permits a + // provider that caches internally, so that older fetch can resolve to the very token the server + // rejected. A `stamp()` here would coalesce onto it and re-send the rejected token. + const cache = new BearerTokenCache(); + const resolvers: ((token: ReturnType) => void)[] = + []; + let invocations = 0; + const provider: TokenProvider = () => { + invocations += 1; + return new Promise(resolve => { + resolvers.push(resolve); + }); + }; + + const stale = cache.stamp(fetchWith(provider, 0, 0)); // starts fetch #1 and parks it in flight + expect(invocations).toBe(1); + + const fresh = cache.refreshPostEviction(fetchWith(provider, 0, 0)); + expect(invocations).toBe(2); // a SECOND provider call, not a handle on the first + + resolvers[0]?.(createBearerToken('rejected-token', 10_000)); + resolvers[1]?.(createBearerToken('genuinely-fresh', 10_000)); + await stale; + expect((await fresh).token).toBe('genuinely-fresh'); + }); + + test('a superseded fetch resolving LAST still cannot re-cache the rejected token', async () => { + // Same hazard, opposite resolution order -- the one a generation-less cache gets wrong: the + // pre-401 fetch settles after the fresh one and would otherwise overwrite it. + const cache = new BearerTokenCache(); + const resolvers: ((token: ReturnType) => void)[] = + []; + const provider: TokenProvider = () => + new Promise(resolve => { + resolvers.push(resolve); + }); + + const stale = cache.stamp(fetchWith(provider, 0, 0)); + const fresh = cache.refreshPostEviction(fetchWith(provider, 0, 0)); + + resolvers[1]?.(createBearerToken('genuinely-fresh', 10_000)); + await fresh; + resolvers[0]?.(createBearerToken('rejected-token', 10_000)); + await stale; + await drainMacrotask(); + + const served = await cache.stamp( + fetchWith( + unexpectedProvider('the fresh token is cached and valid'), + 0, + 0, + ), + ); + expect(served.token).toBe('genuinely-fresh'); + }); +}); + +describe('BearerTokenCache: refreshPostEviction caches and re-drives (AUTH-36/AUTH-37)', () => { + test('the EVICTION path supersedes a pre-401 fetch that resolves late', async () => { + // The sibling above drives `stamp` + `refreshPostEviction` directly. This one goes through AUTH-36's + // actual 401 sequence -- evict, then refreshPostEviction -- with the pre-401 background fetch resolving + // to exactly the token the server rejected, which AUTH-11 expressly permits a + // internally-caching provider to do. + const cache = new BearerTokenCache(); + let releasePreFetch: (() => void) | undefined; + const slow: TokenProvider = () => + new Promise(resolve => { + releasePreFetch = () => { + resolve(createBearerToken('rejected-token', 10_000)); + }; + }); + + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('rejected-token', 1000)).provider, + 0, + 0, + ), + ); + await cache.stamp(fetchWith(slow, 200, 900)); // parks a pre-401 background fetch in flight + + expect(cache.evict('Bearer rejected-token')).toBeUndefined(); + const fresh = await cache.refreshPostEviction( + fetchWith( + providerReturning(createBearerToken('genuinely-fresh', 100_000)) + .provider, + 0, + 900, + ), + ); + expect(fresh.token).toBe('genuinely-fresh'); + + releasePreFetch?.(); + await drainMacrotask(); + + const served = await cache.stamp( + fetchWith( + unexpectedProvider('the post-eviction token is cached and valid'), + 0, + 1000, + ), + ); + expect(served.token).toBe('genuinely-fresh'); + }); + + test('caches its result like any other fetch', async () => { + const cache = new BearerTokenCache(); + const {provider, callCount} = providerReturning( + createBearerToken('t1', 10_000), + ); + await cache.refreshPostEviction(fetchWith(provider, 0, 0)); + const again = await cache.stamp( + fetchWith( + unexpectedProvider('refreshPostEviction() populated the cache'), + 0, + 0, + ), + ); + expect(again.token).toBe('t1'); + expect(callCount()).toBe(1); + }); +}); + +describe('BearerTokenCache: a 401 burst coalesces (AUTH-34/AUTH-37)', () => { + test('N concurrent post-eviction refreshes share ONE provider fetch, not N', async () => { + // A server-side revocation 401s every in-flight request at once. Superseding the pre-401 fetch is + // required (AUTH-37), but starting one provider call per 401 is the thundering herd AUTH-34's + // "at most one provider fetch" clause forbids. + const cache = new BearerTokenCache(); + let calls = 0; + let release: ((token: BearerToken) => void) | undefined; + const provider = (): Promise => { + calls += 1; + return new Promise(resolve => { + release = resolve; + }); + }; + + const burst = [ + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + cache.refreshPostEviction(fetchWith(provider, 0, 0)), + ]; + release?.(createBearerToken('fresh', 10_000)); + const tokens = await Promise.all(burst); + + expect(calls).toBe(1); + expect(tokens.map(token => token.token)).toEqual([ + 'fresh', + 'fresh', + 'fresh', + 'fresh', + ]); + }); + + test('a LATER 401 still supersedes: it does not join the settled burst fetch', async () => { + const cache = new BearerTokenCache(); + const first = providerReturning(createBearerToken('t1', 10_000)); + await cache.refreshPostEviction(fetchWith(first.provider, 0, 0)); + const second = providerReturning(createBearerToken('t2', 10_000)); + + const result = await cache.refreshPostEviction( + fetchWith(second.provider, 0, 0), + ); + + expect(result.token).toBe('t2'); + expect(second.callCount()).toBe(1); + }); + + test('a stamp()-driven fetch sitting at the current generation is NOT joined by refreshPostEviction', async () => { + // The guard is `inFlightEvictionGeneration`, not the generation counter alone: an ordinary + // single-flight fetch must still be superseded, or a pre-401 fetch could hand back the very token + // the server just rejected (AUTH-37). + const cache = new BearerTokenCache(); + let release: ((token: BearerToken) => void) | undefined; + const stale = (): Promise => + new Promise(resolve => { + release = resolve; + }); + const pending = cache.stamp(fetchWith(stale, 0, 0)); + + const fresh = providerReturning(createBearerToken('fresh', 10_000)); + const result = await cache.refreshPostEviction( + fetchWith(fresh.provider, 0, 0), + ); + + expect(result.token).toBe('fresh'); + expect(fresh.callCount()).toBe(1); + release?.(createBearerToken('rejected', 10_000)); + await pending; + }); +}); + +describe('BearerTokenCache: evict (AUTH-36)', () => { + test('evicts only when the header value matches the exact cached token', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 10_000)).provider, + 0, + 0, + ), + ); + // The survivor is RETURNED, which is what makes AUTH-36's "preserving a token another request + // already refreshed" observable rather than a no-op the next fetch overwrites. + expect(cache.evict('Bearer some-other-token')?.token).toBe('t1'); + const result = await cache.stamp( + fetchWith( + unexpectedProvider('a non-matching evict() must not clear the cache'), + 0, + 0, + ), + ); + expect(result.token).toBe('t1'); + }); + + test('a matching evict() forces the next call to refetch', async () => { + const cache = new BearerTokenCache(); + await cache.stamp( + fetchWith( + providerReturning(createBearerToken('t1', 10_000)).provider, + 0, + 0, + ), + ); + expect(cache.evict('Bearer t1')).toBeUndefined(); + const {provider, callCount} = providerReturning( + createBearerToken('t2', 10_000), + ); + const result = await cache.stamp(fetchWith(provider, 0, 0)); + expect(result.token).toBe('t2'); + expect(callCount()).toBe(1); + }); + + test('is a no-op returning undefined when nothing is cached', () => { + expect(new BearerTokenCache().evict('Bearer anything')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/auth/bearer-cache.ts b/packages/core/src/auth/bearer-cache.ts new file mode 100644 index 0000000..b793975 --- /dev/null +++ b/packages/core/src/auth/bearer-cache.ts @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/bearer-cache.ts +import { + isBearerTokenExpired, + type BearerToken, + type TokenProvider, +} from './credential.js'; +import {AuthResolutionError} from './errors.js'; + +/** + * The value {@link BearerTokenCache.inFlightEvictionGeneration} carries when the in-flight fetch (if + * any) came from the ordinary {@link BearerTokenCache.stamp} path. Never a real generation: the + * counter only ever increments from 0, so no post-eviction fetch can collide with it. + */ +const NO_EVICTION_GENERATION = -1; + +/** + * One token fetch's inputs. + * + * Bundled rather than passed positionally: `max-params` is 3, and + * `docs/knowledge/function-design.md` requires an options object at three or more parameters anyway. + * + * @internal + */ +export interface BearerFetch { + /** The token source (AUTH-11). */ + readonly provider: TokenProvider; + /** AUTH-34's refresh margin: how long before expiry a token counts as expiring. */ + readonly marginMs: number; + /** + * The injected clock reading. Never `Date.now()` inside this class — see {@link + * BearerTokenCache.refresh}. + */ + readonly nowMs: number; + /** + * The calling request's cancellation. + * + * It is NOT handed to the provider. A fetch reached through AUTH-34's single-flight coalescing is + * shared by every caller that joined it, so cancelling it on one caller's signal would reject + * callers who never aborted -- and a caller who supplied no signal at all. Instead each caller + * RACES the shared promise against its own signal ({@link raceAbort}): an aborting caller stops + * waiting, and the work the others are joined to keeps running. {@link TokenProvider} therefore + * takes no parameters at all, and carries the deadline obligation that follows from the fetch + * itself being uncancellable. + */ + readonly signal: AbortSignal | undefined; +} + +/** + * Calls `provider` so that a SYNCHRONOUS failure reaches the async channel like every other provider + * failure (AUTH-37, AUTH-38). + * + * A `TokenProvider` is caller-supplied code. Its declared return type is `Promise`, but + * a plain-JS provider can throw before returning, or return something that is not a promise at all -- + * the same boundary the `null | undefined` widening in {@link BearerTokenCache.refresh} already + * distrusts, distrusted the same way. Left as a bare `provider(...)` call, such a failure escaped past + * `stamp`'s `void ... .catch(...)` before the catch was ever attached, turning AUTH-37's expressly + * non-fatal background refresh into a fatal one and rejecting a request that had a perfectly good + * cached token to stamp. + * + * An `async` wrapper, NOT `Promise.resolve().then(provider)`: an async function body runs + * synchronously up to its first `await`, so the provider is still invoked in the same tick as the + * `inFlight` assignment. Deferring it by a microtask would put an await between the single-flight + * check and the assignment -- the one thing the guard's lock-free correctness rests on. Returning + * `provider()` unawaited is likewise deliberate: `await`ing it here would trip `return-await` outside + * a try, and buys nothing, because the `async` keyword already converts a synchronous throw into a + * rejection. + * + * No `signal` is passed because {@link TokenProvider} takes no parameters at all: a coalesced fetch + * is owned by no single call, so there is nothing a caller signal could correctly mean here. See + * {@link BearerFetch.signal} for what happens instead; the provider owns its own deadline. + * + * Collapse this back into a bare `provider()` call only if `TokenProvider` stops being + * caller-supplied code. + */ +async function invokeProvider(provider: TokenProvider): Promise { + return provider(); +} + +/** + * Starts (or joins) a fetch and awaits it, but stops waiting when `signal` aborts -- WITHOUT + * cancelling the fetch, which is shared by every caller coalesced onto it (AUTH-34). + * + * Takes a factory rather than a promise so the already-aborted check runs BEFORE any fetch is + * started, and so `start()` is still invoked in the caller's own synchronous span: an `async` + * function body runs to its first `await` synchronously, which is what keeps the single-flight + * assignment and the generation bump un-interleaved. + * + * `new Promise` with a synchronous executor adapting an event-emitter callback is the one shape + * `docs/knowledge/concurrency-and-async.md` sanctions for it, and the listener is removed on every + * exit so a long-lived caller signal does not accumulate one per token fetch. + * + * A `pending` that rejects after losing the race is still settled through `Promise.race`'s own + * handler, so it never becomes an unhandled rejection. + */ +async function raceAbort( + start: () => Promise, + signal: AbortSignal | undefined, +): Promise { + // Before `start()`, so an already-dead caller never opens a fetch it cannot use -- + // `concurrency-and-async.md`'s "check the signal before each expensive step". + if (signal?.aborted === true) throw signal.reason as Error; + const pending = start(); + if (signal === undefined) return pending; + let onAbort = (): void => undefined; + try { + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + onAbort = (): void => { + reject(signal.reason as Error); + }; + signal.addEventListener('abort', onAbort, {once: true}); + }), + ]); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + +/** + * The single-flight, three-zone bearer token cache (AUTH-34, AUTH-35, AUTH-37). + * + * The async three-zone policy is shipped unconditionally. This port has one `Promise`-only pipeline + * execution model (4c), so AUTH-34's "non-blocking hot-path read of a valid cached token" is the + * fresh-zone branch of this same state machine, not a second stack — directly parallel to 5a's + * one-retry-engine disposition of RETRY-28. + * + * Single-flight is a plain field, not a lock. On Node and Bun the only hazard would be two logical + * calls both observing "no in-flight fetch" before either assigns the slot, and that cannot happen + * because nothing awaits between the check and the assignment in {@link BearerTokenCache.refresh} — + * the same synchronous-guard collapse as Digest's nonce counter. + * + * One instance per configured {@link TokenProvider}; every test constructs its own. + * + * @internal + */ +export class BearerTokenCache { + private cached: BearerToken | undefined; + private inFlight: Promise | undefined; + /** + * Bumped by {@link BearerTokenCache.refreshPostEviction} to supersede every fetch already in + * flight. A superseded fetch still resolves to its own caller, but must not publish its token into + * `cached` or clear the newer fetch's `inFlight` slot — otherwise a pre-401 fetch resolving late + * would re-cache exactly the token the server rejected, which is the outcome AUTH-37 forbids. + */ + private generation = 0; + /** + * The generation an EVICTION-DRIVEN fetch currently in flight was started at, or + * {@link NO_EVICTION_GENERATION} when the in-flight fetch (if any) came from the ordinary + * {@link BearerTokenCache.stamp} path. + * + * This is what lets {@link BearerTokenCache.refreshPostEviction} coalesce without re-opening the + * hazard it exists to close. A pre-401 fetch must never be joined -- it can resolve to the very + * token the server just rejected -- but two 401s on the SAME token arriving together should share + * one fetch, or a mass revocation turns every in-flight request into its own provider call, which + * is exactly the thundering herd AUTH-34's single-flight clause forbids. Comparing this against + * `generation` separates the two cases exactly: only a fetch started by `refreshPostEviction` AT + * the current generation is a genuine post-eviction fetch. + */ + private inFlightEvictionGeneration = NO_EVICTION_GENERATION; + + /** + * AUTH-34/AUTH-37's three zones: fresh (stamp, no refresh), expiring-but-valid (stamp the stale + * token, refresh in the background), expired or missing (await a fresh single-flight fetch). + * + * @param fetchOptions - the provider, margin, injected clock reading, and call signal. + * @returns the token to stamp. + * @throws AuthResolutionError when the provider yields null or an already-expired token (AUTH-35). + */ + async stamp(fetchOptions: BearerFetch): Promise { + const {marginMs, nowMs} = fetchOptions; + if (this.cached !== undefined) { + const expiring = isBearerTokenExpired(this.cached, nowMs, marginMs); + if (!expiring) return this.cached; // fresh zone: stamp, no refresh + const expired = isBearerTokenExpired(this.cached, nowMs, 0); // AUTH-35: no margin at fetch time + if (!expired) { + const stillValid = this.cached; + // Expiring-but-valid zone: fire-and-forget, and the catch is BLANKET on purpose. AUTH-37 is + // unconditional -- "a failed/unusable BACKGROUND refresh MUST NOT fail the in-flight + // request (log-and-continue)" -- and a bare `void this.refresh(...)` would leave the + // rejection unhandled, which under Node's default policy terminates the process. + // + // An earlier shape re-threw an InvariantViolation here, reasoning that a programmer error + // must crash loudly. That was wrong twice over. The throw landed in a promise nobody awaits, + // so it did not surface at the fault -- it killed the host process asynchronously, with no + // request to attribute it to, while the request that triggered it had already been served a + // valid token. And the fault it re-raised is not ours: a blank token from a caller-supplied + // `TokenProvider` is an operational fault (an empty environment variable, a malformed IdP + // payload) as often as a coding one. `error-handling.md`'s crash-loudly rule governs OUR + // invariants at the point WE detect them; it does not license re-raising someone else's + // failure into a detached promise. + // + // The half AUTH-37 asks for and this cannot yet do is the LOG in "log-and-continue" -- + // tracked as G12 against Phase 7b's logging step, and recorded in the phase checklist's + // Deviation Ledger alongside the standing `error-handling.md` conflict this sits on. + // + // Not raced against `fetchOptions.signal`: this refresh belongs to the cache, not to the + // request that happened to trigger it, and it must outlive that request's cancellation. + void this.refresh(fetchOptions, NO_EVICTION_GENERATION).catch( + () => undefined, + ); + return stillValid; + } + } + // Expired/missing zone: await a fresh single-flight fetch, but only until this caller's own + // signal fires (AUTH-34, and `concurrency-and-async.md`'s honour-the-signal rule). + return raceAbort( + () => this.refresh(fetchOptions, NO_EVICTION_GENERATION), + fetchOptions.signal, + ); + } + + /** + * AUTH-37's post-eviction path: a fetch guaranteed to have STARTED after a 401 in this eviction + * burst, never before one. + * + * {@link BearerTokenCache.stamp} is not a substitute. It routes through `refresh`, which hands back + * an already-in-flight promise — and that fetch may have started BEFORE the 401 arrived. AUTH-11 + * explicitly permits a provider that caches or refreshes internally, so such a fetch can resolve to + * the very token the server just rejected, which is precisely what AUTH-37's "re-stamp a single + * retry with a freshly fetched token" forbids. + * + * It does NOT bypass single-flight wholesale, which an earlier shape did: under a mass revocation + * every in-flight request gets its own 401, and starting one provider fetch per 401 is the + * thundering herd AUTH-34's "at most one provider fetch" clause exists to prevent. Coalescing is + * gated on {@link BearerTokenCache.inFlightEvictionGeneration} instead, so concurrent 401s share + * one post-eviction fetch while a pre-401 fetch is still always superseded. That is why the name + * is `refreshPostEviction` rather than `refreshNow`: this call may JOIN a sibling 401's fetch, and + * what it actually guarantees is that no fetch predating this eviction burst is ever joined. + * + * @param fetchOptions - the provider, margin, injected clock reading, and call signal. + * @returns the freshly fetched token. + * @throws AuthResolutionError when the provider yields null or an already-expired token (AUTH-35). + */ + // `async` for AUTH-38's uniform error model: this path runs caller-supplied provider code, and a + // provider that fails BEFORE returning a promise would otherwise throw synchronously out of a + // method whose declared return type is `Promise`. + async refreshPostEviction(fetchOptions: BearerFetch): Promise { + return raceAbort( + () => this.startPostEviction(fetchOptions), + fetchOptions.signal, + ); + } + + /** + * The join-or-supersede decision, split out so {@link raceAbort} can gate it on the caller's signal + * without the generation bump drifting out of the caller's synchronous span. Nothing awaits between + * the `inFlight` read and the write, which is what makes single-flight lock-free. + */ + private startPostEviction(fetchOptions: BearerFetch): Promise { + if ( + this.inFlight !== undefined && + this.inFlightEvictionGeneration === this.generation + ) { + // Another 401 in this same burst already started a post-eviction fetch: join it (AUTH-34). + return this.inFlight; + } + this.generation += 1; // supersede every fetch already in flight + this.inFlight = undefined; // drop the pre-401 fetch's claim on the slot before starting a new one + return this.refresh(fetchOptions, this.generation); + } + + /** + * AUTH-36: clears the cache only when the currently-cached token is the exact one that produced the + * 401, matched on the stamped header value — so a token another in-flight request already refreshed + * survives. + * + * The survivor is RETURNED, not merely left in place, because that is the only way AUTH-36's + * "preserving a token another request already refreshed" clause becomes observable. Preserving it + * and then unconditionally fetching a replacement — which is what the caller did before — overwrote + * the preserved token on the next tick and made the whole clause a no-op. + * + * @param rejectedHeaderValue - the `Authorization` value the rejected request carried. + * @returns the cached token when it is NOT the rejected one (another request already refreshed it, + * so the retry should stamp this instead of fetching again), or `undefined` when the rejected + * token was evicted or nothing was cached. + */ + evict(rejectedHeaderValue: string): BearerToken | undefined { + if (this.cached === undefined) return undefined; + if (`Bearer ${this.cached.token}` === rejectedHeaderValue) { + this.cached = undefined; + return undefined; + } + return this.cached; + } + + /** + * `nowMs` is threaded in rather than read from `Date.now()`: `stamp()` already takes an injected + * clock, and a refresh validating against the ambient wall clock while its caller reasons about an + * injected one would be a second, invisible clock — it would reject every token under synthetic + * time and be uncontrollable in production. + */ + private refresh( + fetchOptions: BearerFetch, + evictionGeneration: number, + ): Promise { + // Returns the RAW shared promise, never one raced against a caller signal. `raceAbort` is applied + // by the two public entry points instead, so the background refresh in `stamp()` -- which belongs + // to the cache rather than to any one request -- is deliberately left unraced. + if (this.inFlight !== undefined) return this.inFlight; // coalesce concurrent expiring/missing callers + const generation = this.generation; + // The generation is passed in rather than derived from a boolean flag: the ordinary path writes + // `NO_EVICTION_GENERATION` so a later `refreshPostEviction` cannot mistake a `stamp()`-driven + // fetch that happens to sit at the current generation for a post-eviction one and join it. + this.inFlightEvictionGeneration = evictionGeneration; + const pending = invokeProvider(fetchOptions.provider) + .then((token: BearerToken | null | undefined) => { + // `token` is widened at this ONE boundary on purpose. `TokenProvider`'s declared return type + // is non-nullable, so comparing the un-widened value against null trips + // `@typescript-eslint/no-unnecessary-condition` from the strict-type-checked tier -- but + // AUTH-35 requires a RUNTIME guard, because a plain-JS caller (or a mis-typed `any` + // boundary) can hand back null regardless of what the type says. Widening states that intent + // instead of suppressing the rule. + if ( + token === null || + token === undefined || + isBearerTokenExpired(token, fetchOptions.nowMs, 0) + ) { + throw new AuthResolutionError( + 'token provider returned a null or already-expired token', + ); // AUTH-35 + } + if (generation === this.generation) this.cached = token; + return token; + }) + .finally(() => { + // Never cache a rejection (AUTH-11/AUTH-35) -- it already propagates through `finally` + // untouched, so no `catch` is added. Guarded on the generation so a superseded fetch cannot + // clear a newer fetch's slot. + if (generation === this.generation) this.inFlight = undefined; + }); + this.inFlight = pending; + return pending; + } +} diff --git a/packages/core/src/auth/challenge.test.ts b/packages/core/src/auth/challenge.test.ts new file mode 100644 index 0000000..df5b787 --- /dev/null +++ b/packages/core/src/auth/challenge.test.ts @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/challenge.test.ts +// Exercises: AUTH-12 (scheme/param names lower-cased, values verbatim, token68 under its synthetic +// key), AUTH-13 (total: blank -> [], malformed recovers at the next top-level comma, unterminated +// quote ends at EOF, params before a malformed tail kept), and the multi-challenge/comma-ambiguity +// case that is the whole reason this parser cannot be a `.split(',')`. +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {parseChallenges} from './challenge.js'; + +describe('a single challenge', () => { + test('scheme and param names are lower-cased; values are verbatim', () => { + const [challenge] = parseChallenges('BASIC Realm="MixedCase"'); + expect(challenge?.scheme).toBe('basic'); + expect(challenge?.params.get('realm')).toBe('MixedCase'); + }); + + test('a bare scheme with no params gets an empty parameter map', () => { + const [challenge] = parseChallenges('NTLM'); + expect(challenge?.scheme).toBe('ntlm'); + expect(challenge?.params.size).toBe(0); + }); + + test('a token68 value is recorded under the synthetic key', () => { + const [challenge] = parseChallenges( + 'Negotiate a87421000492aa874209af8bc028', + ); + expect(challenge?.scheme).toBe('negotiate'); + // AUTH-12 names this key literally as 'token68'. + expect(challenge?.params.get('token68')).toBe( + 'a87421000492aa874209af8bc028', + ); + }); + + test("token68's own '=' padding is part of the value, not an assignment", () => { + const [challenge] = parseChallenges('Negotiate YWJj=='); + expect(challenge?.params.get('token68')).toBe('YWJj=='); + }); + + test('an unquoted token value is accepted', () => { + const [challenge] = parseChallenges('Digest realm=simple, qop=auth'); + expect(challenge?.params.get('realm')).toBe('simple'); + expect(challenge?.params.get('qop')).toBe('auth'); + }); + + // Only the WRAPPER is frozen, and the name says so. `Object.freeze` on the `params` `Map` would + // not stop `.set()`, so freezing it would be a comment that lies; the `ReadonlyMap` TYPE is the + // only guard on the parameters, for the same reason `createAuthRequirement` records for its own + // params map -- `Challenge` is `@internal`, never reaches a consumer, and nothing in this package + // re-casts `Challenge['params']` back to `Map`. + test('the returned challenge wrapper is frozen', () => { + const [challenge] = parseChallenges('Basic realm="a"'); + expect(Object.isFrozen(challenge)).toBe(true); + }); +}); + +describe('multiple comma-separated challenges', () => { + test('a top-level comma between two DIFFERENT auth-params of the SAME challenge does not start a new one', () => { + const challenges = parseChallenges( + 'Digest realm="a", nonce="n", qop="auth"', + ); + expect(challenges).toHaveLength(1); + expect(challenges[0]?.params.get('realm')).toBe('a'); + expect(challenges[0]?.params.get('nonce')).toBe('n'); + expect(challenges[0]?.params.get('qop')).toBe('auth'); + }); + + test('two distinct challenges are both recovered, each with its own params', () => { + const challenges = parseChallenges( + 'Basic realm="a", Digest realm="b", nonce="n"', + ); + expect(challenges).toHaveLength(2); + expect(challenges[0]).toEqual({ + scheme: 'basic', + params: new Map([['realm', 'a']]), + }); + expect(challenges[1]?.scheme).toBe('digest'); + expect(challenges[1]?.params.get('realm')).toBe('b'); + expect(challenges[1]?.params.get('nonce')).toBe('n'); + }); + + test('a comma INSIDE a quoted value never splits the challenge', () => { + const [challenge] = parseChallenges('Digest realm="a, b", nonce="n"'); + expect(challenge?.params.get('realm')).toBe('a, b'); + expect(challenge?.params.get('nonce')).toBe('n'); + }); + + test('wire order is preserved', () => { + const challenges = parseChallenges('Digest realm="d", Basic realm="b"'); + expect(challenges.map(c => c.scheme)).toEqual(['digest', 'basic']); + }); +}); + +describe('quoted-string handling', () => { + test('a backslash escape is unquoted', () => { + const [challenge] = parseChallenges(String.raw`Digest realm="a\"b"`); + expect(challenge?.params.get('realm')).toBe('a"b'); + }); + + test('an unterminated quoted string terminates at end-of-input (AUTH-13)', () => { + const [challenge] = parseChallenges('Digest realm="abc'); + expect(challenge?.params.get('realm')).toBe('abc'); + }); + + test('an equals sign inside a quoted value is not an assignment', () => { + const [challenge] = parseChallenges('Digest realm="a=b", nonce="n"'); + expect(challenge?.params.get('realm')).toBe('a=b'); + expect(challenge?.params.get('nonce')).toBe('n'); + }); +}); + +describe('totality and recovery (AUTH-13)', () => { + test('blank input yields an empty list', () => { + expect(parseChallenges('')).toEqual([]); + expect(parseChallenges(' ')).toEqual([]); + }); + + test('a malformed segment recovers at the next top-level comma, keeping prior params', () => { + const challenges = parseChallenges( + 'Digest realm="a", =bad, Basic realm="b"', + ); + expect(challenges).toHaveLength(2); + expect(challenges[0]).toEqual({ + scheme: 'digest', + params: new Map([['realm', 'a']]), + }); + expect(challenges[1]).toEqual({ + scheme: 'basic', + params: new Map([['realm', 'b']]), + }); + }); + + test('a leading auth-param with no scheme ahead of it is discarded, not crashed on', () => { + const challenges = parseChallenges('realm="orphan", Basic realm="b"'); + expect(challenges).toHaveLength(1); + expect(challenges[0]?.scheme).toBe('basic'); + }); + + test('a comma inside the malformed segment’s quoted value is not a recovery point', () => { + const challenges = parseChallenges( + 'Basic realm="a", ="x,y", Digest realm="b"', + ); + expect(challenges.map(c => c.scheme)).toEqual(['basic', 'digest']); + }); + + test('an escaped quote inside a malformed segment does not end its quoted run', () => { + // The `\"` keeps the run open, so the comma that follows is still inside quotes and is not a + // recovery point; recovery lands on the one after the closing quote. + const challenges = parseChallenges( + String.raw`Basic realm="a", ="x\",y", Digest realm="b"`, + ); + expect(challenges.map(c => c.scheme)).toEqual(['basic', 'digest']); + }); + + test('stray commas collapse rather than emitting empty challenges', () => { + expect(parseChallenges(',,,')).toEqual([]); + expect( + parseChallenges('Basic,,Digest realm="b"').map(c => c.scheme), + ).toEqual(['basic', 'digest']); + }); +}); + +describe('parser totality, as properties (AUTH-13)', () => { + test('property: never throws for arbitrary input', () => { + fc.assert( + fc.property(fc.string(), raw => { + expect(() => parseChallenges(raw)).not.toThrow(); + }), + ); + }); + + test('property: never throws and always terminates over a metacharacter corpus', () => { + // `fc.string()` above rarely produces dense clusters of the characters that actually drive this + // parser's recovery branches, and one of those branches (`readSchemeTail` resetting to its saved + // position when a token68 read comes back empty) advances nothing on its own -- termination rests + // on the outer loop consuming instead. This enumerates that alphabet exhaustively at length 5. + const alphabet = [' ', ',', '=', '"', '\\', '/', '!', '@', 'a', '\t']; + for (let seed = 0; seed < 100_000; seed += 1) { + let text = ''; + let n = seed; + for (let i = 0; i < 5; i += 1) { + text += alphabet[n % alphabet.length] ?? ''; + n = Math.floor(n / alphabet.length); + } + expect(() => parseChallenges(text)).not.toThrow(); + } + }); + + test('a 100 KB quoted value stays linear and yields one challenge', () => { + expect( + parseChallenges(`Basic realm="${'x'.repeat(100_000)}"`), + ).toHaveLength(1); + }); + + test('property: a well-formed single challenge round-trips scheme + one param exactly', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{1,10}$/u), + fc.stringMatching(/^[a-zA-Z0-9 ]{0,20}$/u), + (scheme, value) => { + const [challenge] = parseChallenges(`${scheme} realm="${value}"`); + expect(challenge?.scheme).toBe(scheme.toLowerCase()); + expect(challenge?.params.get('realm')).toBe(value); + }, + ), + ); + }); + + test('property: a comma inside a quoted value never splits the challenge', () => { + fc.assert( + fc.property(fc.stringMatching(/^[a-zA-Z0-9]{0,8}$/u), fragment => { + const challenges = parseChallenges( + `Digest realm="${fragment},${fragment}", nonce="n"`, + ); + expect(challenges).toHaveLength(1); + expect(challenges[0]?.params.get('realm')).toBe( + `${fragment},${fragment}`, + ); + }), + ); + }); +}); diff --git a/packages/core/src/auth/challenge.ts b/packages/core/src/auth/challenge.ts new file mode 100644 index 0000000..5428702 --- /dev/null +++ b/packages/core/src/auth/challenge.ts @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/challenge.ts + +/** + * One parsed RFC 7235 challenge (AUTH-12): the scheme plus its auth-params. + * + * Scheme and parameter names are lower-cased; parameter values are stored verbatim after unquoting. + * + * @internal + */ +export interface Challenge { + /** The challenge scheme, lower-cased. */ + readonly scheme: string; + /** Auth-params, keys lower-cased, values verbatim after unquoting. */ + readonly params: ReadonlyMap; +} + +/** + * What a handler needs from the request being stamped, beyond the challenge itself. + * + * Digest's HA2 is computed over the method and the request-target (RFC 7616 §3.4.3), neither of which + * the challenge carries. Passed as a small context object rather than the whole `Request` so a handler + * cannot reach the body or headers it has no business reading. + * + * @internal + */ +export interface DigestUriContext { + /** The request method, upper-case, as it goes on the wire. */ + readonly method: string; + /** The digest-uri: the request-target (path plus query) of the request being stamped. */ + readonly requestTarget: string; +} + +/** + * One challenge-reactive stamping strategy (AUTH-23–AUTH-25). Implemented by `basic.ts` and + * `digest.ts`, composed by `composing-handler.ts`. + * + * Throughout `auth/`, to STAMP means to PRODUCE the value the caller writes, never to write it: every + * `stamp` in this module (`ChallengeHandler.stamp`, `ComposingHandler.stamp`, `stampStaticKey`, + * `BearerTokenCache.stamp`) returns credential material and leaves the header write to `auth-step.ts`. + * + * Declared here rather than in `composing-handler.ts` so both implementations can be written before + * the composer exists, and because `challenge.ts` already owns the {@link Challenge} type both + * methods operate on. + * + * `stamp()` is asynchronous because Digest's SHA-256/SHA-256-sess algorithms compute HA1/HA2/response + * through `crypto.subtle.digest()`, and Web Crypto offers no synchronous digest to fall back to. + * Basic's implementation resolves immediately. + * + * @internal + */ +export interface ChallengeHandler { + /** + * AUTH-16/AUTH-25: whether this handler can answer `challenge`. + * + * @param challenge - the offered challenge. + * @returns `true` when this handler can produce a header value for it. + */ + canHandle(challenge: Challenge): boolean; + + /** + * Produces the header VALUE only — the caller picks `Authorization` vs `Proxy-Authorization` from + * which challenge header the status actually carried (AUTH-25). + * + * There is deliberately no `isProxy` parameter. It was one, threaded from `auth-step.ts` through + * `composing-handler.ts` into both implementations, and NEITHER read it: Basic's value is computed + * once at construction and Digest's depends only on the challenge and the request-target, so the + * only test either could carry for the parameter was one asserting it changed nothing. AUTH-25's + * origin-vs-proxy choice lives entirely in `auth-step.ts`'s `answerHeaderName`, which is the one + * place that knows which challenge header the status carried. A future scheme whose VALUE differs + * by proxy-ness would add it back — and would then have something to assert about it. + * + * @param challenge - the challenge being answered; `canHandle` has already passed. + * @param request - the request being stamped. Optional so a handler needing neither method nor + * target (Basic) is callable with one argument; Digest asserts its presence. + * @returns the header value. + */ + stamp(challenge: Challenge, request?: DigestUriContext): Promise; + + /** + * AUTH-16's "earliest in the configured preference list, not wire order": when a server offers + * several challenges a single handler could equally satisfy — RFC 7616 uses repeated Digest + * challenges differing only by `algorithm` as an algorithm-discovery mechanism — `canHandle` alone + * can only answer yes/no per challenge, never express a preference among them. + * + * Lower is more preferred. Optional: a handler with no algorithm variants (Basic) omits it and is + * treated as rank 0. + * + * @param challenge - the offered challenge. + * @returns the preference rank; lower wins. + */ + rank?(challenge: Challenge): number; +} + +/** + * AUTH-12 names this key literally: a token68 value is "recorded under a synthetic key", spelled + * `token68` in the requirement's own text. A genuine `token68=...` auth-param does not exist in RFC + * 7235's grammar — token68 is positional, never `name=value` — so no collision with a real parameter + * is possible. + */ +const TOKEN68_KEY = 'token68'; +const TOKEN_CHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z]/u; +const TOKEN68_CHAR = /[A-Za-z0-9\-._~+/]/u; + +interface Scanner { + readonly text: string; + pos: number; +} + +/** + * The one place BWS (RFC 7230's optional SP/HTAB run) is skipped, shared by the Scanner-driven walk + * and the two lookahead predicates below -- which cannot take a `Scanner`, because they must not + * advance one. + */ +function skipBwsFrom(text: string, from: number): number { + let index = from; + while (index < text.length && (text[index] === ' ' || text[index] === '\t')) { + index += 1; + } + return index; +} + +function skipSpaces(scanner: Scanner): void { + scanner.pos = skipBwsFrom(scanner.text, scanner.pos); +} + +function readToken(scanner: Scanner): string { + const start = scanner.pos; + while ( + scanner.pos < scanner.text.length && + TOKEN_CHAR.test(scanner.text[scanner.pos] ?? '') + ) + scanner.pos += 1; + return scanner.text.slice(start, scanner.pos); +} + +function readToken68Tail(scanner: Scanner): string { + const start = scanner.pos; + while (scanner.pos < scanner.text.length) { + const char = scanner.text[scanner.pos] ?? ''; + // '=' is token68's own padding, not an assignment: the caller only reaches here after ruling out + // a `name=value` reading. + if (!TOKEN68_CHAR.test(char) && char !== '=') break; + scanner.pos += 1; + } + return scanner.text.slice(start, scanner.pos); +} + +/** + * Honors backslash escapes (AUTH-12). An unterminated string ends at end-of-input rather than + * throwing (AUTH-13). + */ +function readQuotedString(scanner: Scanner): string { + scanner.pos += 1; // opening quote, already confirmed present by the caller + let value = ''; + while (scanner.pos < scanner.text.length) { + // `?? ''` throughout, not a cast: `noUncheckedIndexedAccess` types every index read as + // `string | undefined`, and the loop bound already rules the undefined out. + const char = scanner.text[scanner.pos] ?? ''; + if (char === '\\' && scanner.pos + 1 < scanner.text.length) { + value += scanner.text[scanner.pos + 1] ?? ''; + scanner.pos += 2; + continue; + } + if (char === '"') { + scanner.pos += 1; + return value; + } + value += char; + scanner.pos += 1; + } + return value; +} + +/** + * Consumes, without capturing, up to and including the next top-level comma — the recovery path for a + * malformed segment (AUTH-13). Quote depth is tracked, so a comma inside a quoted value is not a + * recovery point. + */ +function skipToNextTopLevelComma(scanner: Scanner): void { + let inQuotes = false; + while (scanner.pos < scanner.text.length) { + const char = scanner.text[scanner.pos]; + if (char === '\\' && inQuotes && scanner.pos + 1 < scanner.text.length) { + scanner.pos += 2; + continue; + } + if (char === '"') inQuotes = !inQuotes; + else if (char === ',' && !inQuotes) { + scanner.pos += 1; + return; + } + scanner.pos += 1; + } +} + +/** Whether the next non-whitespace character is the `=` of a `name=value` auth-param. */ +function peekIsParamAssignment(text: string, fromPos: number): boolean { + return text[skipBwsFrom(text, fromPos)] === '='; +} + +/** + * Whether a `name=value` reading is viable: an `=` followed, after optional BWS, by a real token or + * quoted-string value. + * + * Only the position immediately after a scheme name needs this stricter test, because that is the one + * place RFC 7235 permits a positional `token68` — and a token68 may END in one or more `=` (base64 + * padding), so `Negotiate YWJj==` would otherwise be misread as an auth-param `ywjj` with an empty + * value. Everywhere else inside a challenge the loose `=` test stands, so a genuinely empty auth-param + * value stays an empty auth-param rather than being re-read as a token68 that cannot appear there. + */ +function peekIsValuedParam(text: string, fromPos: number): boolean { + const equalsAt = skipBwsFrom(text, fromPos); + if (text[equalsAt] !== '=') return false; + const next = text[skipBwsFrom(text, equalsAt + 1)] ?? ''; + return next === '"' || TOKEN_CHAR.test(next); +} + +function readValue(scanner: Scanner): string { + return scanner.text[scanner.pos] === '"' + ? readQuotedString(scanner) + : readToken(scanner); +} + +interface MutableChallenge { + readonly scheme: string; + readonly params: Map; +} + +/** Reads one `name=value` pair into `current`. The caller has already confirmed the `=` follows. */ +function readParamInto( + scanner: Scanner, + name: string, + current: MutableChallenge, +): void { + skipSpaces(scanner); + scanner.pos += 1; // '=' + skipSpaces(scanner); + current.params.set(name.toLowerCase(), readValue(scanner)); + skipSpaces(scanner); + if (scanner.text[scanner.pos] === ',') scanner.pos += 1; +} + +/** Reads the optional token68-or-first-param tail immediately following a freshly-read scheme name. */ +function readSchemeTail(scanner: Scanner, current: MutableChallenge): void { + skipSpaces(scanner); + if (scanner.pos >= scanner.text.length || scanner.text[scanner.pos] === ',') + return; + const savedPos = scanner.pos; + const maybeName = readToken(scanner); + if (maybeName !== '' && peekIsValuedParam(scanner.text, scanner.pos)) { + readParamInto(scanner, maybeName, current); + return; + } + scanner.pos = savedPos; + const token68 = readToken68Tail(scanner); + if (token68 !== '') current.params.set(TOKEN68_KEY, token68); + skipSpaces(scanner); + if (scanner.text[scanner.pos] === ',') scanner.pos += 1; +} + +/** + * Parses an RFC 7235 `WWW-Authenticate`/`Proxy-Authenticate` value into its ordered challenge list + * (AUTH-12). + * + * Total by construction (AUTH-13): it never throws, for any input. Blank input yields `[]`; a + * malformed segment recovers at the next top-level comma through a quote-depth-tracked scan — never a + * naive `.split(',')`, which breaks on a quoted value containing a comma; params parsed before a + * malformed tail are kept; an unterminated quoted string terminates at end-of-input. + * + * Hand-written for the same reason 5a's RFC 1123 `Retry-After` date parser was: there is no built-in + * RFC 7235 parser to lean on, and a general-purpose header splitter would not honor quoted-string + * commas. + * + * @param headerValue - the raw header value. + * @returns the challenges, in wire order. Never throws. + * + * @internal + */ +export function parseChallenges(headerValue: string): readonly Challenge[] { + const challenges: MutableChallenge[] = []; + const scanner: Scanner = {text: headerValue, pos: 0}; + + for (;;) { + skipSpaces(scanner); + if (scanner.pos >= scanner.text.length) break; + if (scanner.text[scanner.pos] === ',') { + scanner.pos += 1; + continue; + } + + const token = readToken(scanner); + if (token === '') { + skipToNextTopLevelComma(scanner); + continue; + } + + if (peekIsParamAssignment(scanner.text, scanner.pos)) { + // A `name=value` with no scheme ahead of it: attach it to the challenge in progress, or discard + // the segment when the header opens with one. + const current = challenges.at(-1); + if (current === undefined) { + skipToNextTopLevelComma(scanner); + continue; + } + readParamInto(scanner, token, current); + continue; + } + + const current: MutableChallenge = { + scheme: token.toLowerCase(), + params: new Map(), + }; + challenges.push(current); + readSchemeTail(scanner, current); + } + + // `Object.freeze` is SHALLOW, and `params` is a `Map`, which `Object.freeze` cannot make read-only + // at all -- `.set()` still succeeds on a frozen Map. The `ReadonlyMap` TYPE is therefore the only + // guard on the parameters, exactly as `createAuthRequirement` documents for its own params map, and + // it holds for the same reason: `Challenge` is `@internal`, never reaches a consumer, and nothing + // in this package re-casts `Challenge['params']` back to `Map`. + return challenges.map(entry => + Object.freeze({scheme: entry.scheme, params: entry.params}), + ); +} diff --git a/packages/core/src/auth/composing-handler.test.ts b/packages/core/src/auth/composing-handler.test.ts new file mode 100644 index 0000000..ca11957 --- /dev/null +++ b/packages/core/src/auth/composing-handler.test.ts @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/composing-handler.test.ts +// Exercises: AUTH-23 (ordered handler list, defensively copied; first configured handler wins), +// AUTH-24 (handler order beats wire-order challenge position), AUTH-25 (returns the value half only +// -- no header-name decision here, and no header at all when nothing is satisfiable), and the +// rank-based tie-break that carries AUTH-16's algorithm preference. +import {describe, expect, test} from 'bun:test'; +import type {Challenge, ChallengeHandler} from './challenge.js'; +import {composingHandler} from './composing-handler.js'; + +function fakeHandler( + scheme: string, + value: string, + rank = 0, +): ChallengeHandler { + return { + canHandle: (challenge: Challenge): boolean => challenge.scheme === scheme, + stamp: (): Promise => Promise.resolve(value), + rank: (): number => rank, + }; +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this runner's + * type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper keeps the + * assertion honest without a lint suppression. Same shape 5a's and 5b's step suites settled on. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('composingHandler', () => { + test('delegates to the first CONFIGURED handler that can satisfy any offered challenge', async () => { + const handler = composingHandler([ + fakeHandler('digest', 'digest-value'), + fakeHandler('basic', 'basic-value'), + ]); + const challenges: readonly Challenge[] = [ + {scheme: 'basic', params: new Map()}, + {scheme: 'digest', params: new Map()}, + ]; + // basic appears FIRST on the wire, but digest's HANDLER is configured first -- handler order wins. + expect(await handler.stamp(challenges)).toBe('digest-value'); + }); + + test('falls through to a later handler when the first cannot satisfy anything offered', async () => { + const handler = composingHandler([ + fakeHandler('digest', 'digest-value'), + fakeHandler('basic', 'basic-value'), + ]); + expect(await handler.stamp([{scheme: 'basic', params: new Map()}])).toBe( + 'basic-value', + ); + }); + + test('returns undefined when no handler can satisfy any offered challenge', async () => { + const handler = composingHandler([fakeHandler('digest', 'x')]); + expect( + await handler.stamp([{scheme: 'basic', params: new Map()}]), + ).toBeUndefined(); + }); + + test('returns undefined for an empty challenge list', async () => { + const handler = composingHandler([fakeHandler('digest', 'x')]); + expect(await handler.stamp([])).toBeUndefined(); + }); + + test('returns undefined when no handlers are configured at all', async () => { + const handler = composingHandler([]); + expect( + await handler.stamp([{scheme: 'digest', params: new Map()}]), + ).toBeUndefined(); + }); +}); + +describe('composingHandler ranking and delegation (AUTH-16/AUTH-23)', () => { + test('within one handler satisfying multiple challenges, rank breaks the tie', async () => { + const digestLike: ChallengeHandler = { + canHandle: challenge => challenge.scheme === 'digest', + stamp: challenge => + Promise.resolve( + `value-for-${challenge.params.get('algorithm') ?? 'default'}`, + ), + rank: challenge => + challenge.params.get('algorithm') === 'SHA-256' ? 0 : 1, + }; + const handler = composingHandler([digestLike]); + const challenges: readonly Challenge[] = [ + {scheme: 'digest', params: new Map([['algorithm', 'MD5']])}, + {scheme: 'digest', params: new Map([['algorithm', 'SHA-256']])}, + ]; + expect(await handler.stamp(challenges)).toBe('value-for-SHA-256'); + }); + + test('rank never outranks handler order -- a worse-ranked earlier handler still wins', async () => { + const handler = composingHandler([ + fakeHandler('digest', 'digest-value', 99), + fakeHandler('basic', 'basic-value', 0), + ]); + const challenges: readonly Challenge[] = [ + {scheme: 'basic', params: new Map()}, + {scheme: 'digest', params: new Map()}, + ]; + expect(await handler.stamp(challenges)).toBe('digest-value'); + }); + + test('a handler with no rank() defaults to 0 and does not crash the sort', async () => { + const noRank: ChallengeHandler = { + canHandle: (): boolean => true, + stamp: (): Promise => Promise.resolve('no-rank-value'), + }; + const handler = composingHandler([noRank]); + expect(await handler.stamp([{scheme: 'anything', params: new Map()}])).toBe( + 'no-rank-value', + ); + }); +}); + +describe('composingHandler isolation and error propagation (AUTH-23)', () => { + test('defensively copies the handler list at construction (AUTH-23)', async () => { + const handlers = [fakeHandler('basic', 'v1')]; + const handler = composingHandler(handlers); + handlers.push(fakeHandler('digest', 'v2')); + // 'digest' was pushed after construction, so the composed handler must not see it. + expect( + await handler.stamp([{scheme: 'digest', params: new Map()}]), + ).toBeUndefined(); + }); + + // There is deliberately no companion test for an `isProxy` flag. One was threaded through this + // composer into both handlers and NEITHER read it, so the only assertion either could carry was + // that it changed nothing; AUTH-25's origin-vs-proxy choice lives in `auth-step.ts`'s + // `answerHeaderName`, which is where it is actually asserted. + test('passes the request context through to the winning handler', async () => { + let observedRequest: {method: string; requestTarget: string} | undefined; + const recorder: ChallengeHandler = { + canHandle: (): boolean => true, + stamp: (_challenge, request): Promise => { + observedRequest = request; + return Promise.resolve('v'); + }, + }; + const handler = composingHandler([recorder]); + await handler.stamp([{scheme: 'x', params: new Map()}], { + method: 'GET', + requestTarget: '/y', + }); + expect(observedRequest).toEqual({method: 'GET', requestTarget: '/y'}); + }); + + test("a rejecting handler's failure propagates rather than being swallowed as 'no replacement'", async () => { + const boom: ChallengeHandler = { + canHandle: (): boolean => true, + stamp: (): Promise => + Promise.reject(new Error('handler blew up')), + }; + const error = await rejectionOf( + composingHandler([boom]).stamp([{scheme: 'x', params: new Map()}]), + ); + expect((error as Error).message).toBe('handler blew up'); + }); +}); diff --git a/packages/core/src/auth/composing-handler.ts b/packages/core/src/auth/composing-handler.ts new file mode 100644 index 0000000..8fa9012 --- /dev/null +++ b/packages/core/src/auth/composing-handler.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/composing-handler.ts +import type { + Challenge, + ChallengeHandler, + DigestUriContext, +} from './challenge.js'; + +/** + * Ordered delegation over a fixed handler list (AUTH-23–AUTH-25). + * + * @internal + */ +export interface ComposingHandler { + /** + * Answers the best challenge any configured handler can satisfy. + * + * @param challenges - every challenge the response offered, in wire order. + * @param request - the request being stamped, for handlers that need method and target. + * @returns the header VALUE, or `undefined` when no handler can satisfy any offered challenge — + * which the auth step reads as "no replacement request" (AUTH-25). + */ + stamp( + challenges: readonly Challenge[], + request?: DigestUriContext, + ): Promise; +} + +interface Candidate { + readonly handlerIndex: number; + readonly rank: number; + readonly handler: ChallengeHandler; + readonly challenge: Challenge; +} + +function collectCandidates( + handlers: readonly ChallengeHandler[], + challenges: readonly Challenge[], +): Candidate[] { + const candidates: Candidate[] = []; + handlers.forEach((handler, handlerIndex) => { + for (const challenge of challenges) { + if (handler.canHandle(challenge)) { + candidates.push({ + handlerIndex, + rank: handler.rank?.(challenge) ?? 0, + handler, + challenge, + }); + } + } + }); + return candidates; +} + +/** + * AUTH-23: handler CONFIGURATION order is the primary key — "the first handler in declaration order + * whose can-handle check passes" wins regardless of where its satisfiable challenge sits on the wire. + * `rank` is the secondary key, carrying AUTH-16's algorithm-preference-over-wire-order rule within a + * single handler. + */ +function bestCandidate( + candidates: readonly Candidate[], +): Candidate | undefined { + return [...candidates].sort( + (a, b) => a.handlerIndex - b.handlerIndex || a.rank - b.rank, + )[0]; +} + +/** + * Composes an ordered handler list into one challenge answerer (AUTH-23–AUTH-25). + * + * The list is defensively copied at construction (AUTH-23), so a caller mutating its array afterwards + * cannot change which handlers this composer consults. Callers order stronger schemes first — the + * auth step builds `[digest, basic]`. + * + * Returns `undefined` — meaning "no replacement request" — when no handler can satisfy any offered + * challenge (AUTH-25). It never throws: an unsatisfiable challenge is an ordinary outcome the auth + * step turns into "leave the 401 unchanged" (AUTH-33), not an error condition. + * + * AUTH-25's `Authorization`-vs-`Proxy-Authorization` choice is NOT threaded through here: this + * composer and both handlers produce the VALUE half only, and `auth-step.ts` picks the header name + * from which challenge header the status actually carried. + * + * Handlers are stateless apart from Digest's per-nonce counter, which is safe for concurrent + * invocation (AUTH-24). + * + * @param handlers - the handlers, strongest first. + * @returns the composed handler. + * + * @internal + */ +export function composingHandler( + handlers: readonly ChallengeHandler[], +): ComposingHandler { + const configured = [...handlers]; + return { + stamp: async (challenges, request): Promise => { + const candidate = bestCandidate( + collectCandidates(configured, challenges), + ); + if (candidate === undefined) return undefined; + return candidate.handler.stamp(candidate.challenge, request); + }, + }; +} diff --git a/packages/core/src/auth/credential.test.ts b/packages/core/src/auth/credential.test.ts new file mode 100644 index 0000000..23f76c9 --- /dev/null +++ b/packages/core/src/auth/credential.test.ts @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/credential.test.ts +// Exercises: AUTH-8 (BearerToken is value-equal; ApiKeyCredential/NameKeyCredential are +// reference-equal via bare `===`, no equals() override; ALL THREE redact their secret in every +// string/diagnostic form and none is reachable through JSON.stringify/Object.keys), AUTH-9 (blank +// rejected as a programmer error, and every type is nominal so the validation cannot be routed +// around), AUTH-10 (expiry math: undefined never locally expires; expired iff nowMs + marginMs > +// expiresAt), AUTH-26 (`credentialKey()` is the sole read path for a static key's secret). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import { + ApiKeyCredential, + BearerToken, + NameKeyCredential, + bearerTokensEqual, + createBearerToken, + credentialKey, + isBearerTokenExpired, +} from './credential.js'; + +// `util.inspect` is not imported: nothing else in `packages/core` reaches for a `node:` module, and +// the hook under test is reachable directly. `console.log`/`util.inspect` call exactly this method. +const INSPECT = Symbol.for('nodejs.util.inspect.custom'); + +function inspectOf( + value: ApiKeyCredential | BearerToken | NameKeyCredential, +): string { + const hooks = value as unknown as Record string) | undefined>; + const hook = hooks[INSPECT]; + expect(typeof hook).toBe('function'); + return hook === undefined ? '' : hook.call(value); +} + +describe('BearerToken', () => { + test('value equality over token + expiry', () => { + const a = createBearerToken('t', 1000); + const b = createBearerToken('t', 1000); + expect(bearerTokensEqual(a, b)).toBe(true); + }); + + test('differing token or expiry is not equal', () => { + expect( + bearerTokensEqual(createBearerToken('a'), createBearerToken('b')), + ).toBe(false); + expect( + bearerTokensEqual(createBearerToken('t', 1), createBearerToken('t', 2)), + ).toBe(false); + }); + + test('an absent expiry and a set expiry are not equal', () => { + expect( + bearerTokensEqual(createBearerToken('t'), createBearerToken('t', 1)), + ).toBe(false); + }); + + test('is frozen', () => { + expect(Object.isFrozen(createBearerToken('t'))).toBe(true); + }); + + test('rejects a blank or whitespace-only token (AUTH-9)', () => { + expect(() => createBearerToken('')).toThrow(InvariantViolation); + expect(() => createBearerToken(' ')).toThrow(InvariantViolation); + }); + + test('rejects a non-finite expiresAt', () => { + // `isBearerTokenExpired` is `nowMs + marginMs > expiresAt`, so a NaN expiry makes every + // comparison false: the token reads as permanently fresh, the cache serves it from the hot path + // forever, and no provider call ever happens to notice. Rejected at construction rather than + // discovered as a dead credential in production. + expect(() => createBearerToken('t', Number.NaN)).toThrow( + 'expiresAt must be a finite epoch', + ); + expect(() => createBearerToken('t', Number.POSITIVE_INFINITY)).toThrow( + 'expiresAt must be a finite epoch', + ); + }); + + test('undefined expiresAt never locally expires (AUTH-10)', () => { + const token = createBearerToken('t'); + expect(isBearerTokenExpired(token, Number.MAX_SAFE_INTEGER, 0)).toBe(false); + expect(isBearerTokenExpired(token, Number.MAX_SAFE_INTEGER, 60_000)).toBe( + false, + ); + }); + + test('expired iff nowMs + marginMs > expiresAt (AUTH-10)', () => { + const token = createBearerToken('t', 1000); + expect(isBearerTokenExpired(token, 999, 0)).toBe(false); + expect(isBearerTokenExpired(token, 1000, 0)).toBe(false); // exactly at expiry, not yet past it + expect(isBearerTokenExpired(token, 1001, 0)).toBe(true); + expect(isBearerTokenExpired(token, 900, 200)).toBe(true); // margin pushes it over + }); +}); + +describe('BearerToken redaction and nominality (AUTH-8/AUTH-9)', () => { + test('toString and inspect redact the token but keep the expiry', () => { + const token = createBearerToken('super-secret', 1000); + expect(token.toString()).not.toContain('super-secret'); + expect(String(token)).not.toContain('super-secret'); + expect(token.toString()).toContain('1000'); + expect(inspectOf(token)).not.toContain('super-secret'); + }); + + test('JSON.stringify and Object.keys cannot reach the token -- #private, not TS private', () => { + const token = createBearerToken('super-secret', 1000); + expect(JSON.stringify(token)).not.toContain('super-secret'); + expect(Object.keys(token)).toEqual(['expiresAt']); + }); + + test('the accessor still hands the real token to the stamping path', () => { + expect(createBearerToken('super-secret').token).toBe('super-secret'); + }); + + test('nominal: an object literal is not a BearerToken, so AUTH-9 cannot be bypassed', () => { + // The compile-time half is the point -- a `TokenProvider` returning `{token: '', expiresAt: + // undefined}` no longer type-checks -- and this asserts the runtime half: the constructor is + // private, so `createBearerToken` (which validates) is the only construction path. + const literal = {token: '', expiresAt: undefined}; + expect(literal instanceof BearerToken).toBe(false); + expect(createBearerToken('t') instanceof BearerToken).toBe(true); + }); +}); + +describe('ApiKeyCredential (AUTH-8)', () => { + test('two instances with identical fields are NOT equal -- reference identity only', () => { + expect( + new ApiKeyCredential('secret') === new ApiKeyCredential('secret'), + ).toBe(false); + }); + + test('toString and inspect redact the key', () => { + const credential = new ApiKeyCredential('super-secret'); + expect(credential.toString()).not.toContain('super-secret'); + expect(String(credential)).not.toContain('super-secret'); + expect(inspectOf(credential)).not.toContain('super-secret'); + }); + + test('JSON.stringify cannot reach the key either -- #private, not TS private', () => { + expect(JSON.stringify(new ApiKeyCredential('super-secret'))).not.toContain( + 'super-secret', + ); + expect(Object.keys(new ApiKeyCredential('super-secret'))).toEqual([]); + }); + + test('rejects a blank or whitespace-only key (AUTH-9)', () => { + expect(() => new ApiKeyCredential('')).toThrow(InvariantViolation); + expect(() => new ApiKeyCredential(' ')).toThrow(InvariantViolation); + }); + + test('the secret is reachable ONLY through the internal credentialKey() hook', () => { + const credential = new ApiKeyCredential('secret'); + expect(credentialKey(credential)).toBe('secret'); + // No public `key` accessor: the friend hook is the whole read path, so the secret never appears + // on the published surface (AUTH-8). + expect('key' in credential).toBe(false); + }); +}); + +describe('NameKeyCredential (AUTH-8)', () => { + test('two instances with identical fields are NOT equal', () => { + expect( + new NameKeyCredential('n', 'k') === new NameKeyCredential('n', 'k'), + ).toBe(false); + }); + + test('toString redacts the key but names the name', () => { + const credential = new NameKeyCredential('x-api-key', 'super-secret'); + expect(credential.toString()).toContain('x-api-key'); + expect(credential.toString()).not.toContain('super-secret'); + expect(inspectOf(credential)).not.toContain('super-secret'); + }); + + test('rejects a blank or whitespace-only name or key (AUTH-9)', () => { + expect(() => new NameKeyCredential('', 'k')).toThrow(InvariantViolation); + expect(() => new NameKeyCredential('n', '')).toThrow(InvariantViolation); + expect(() => new NameKeyCredential(' ', 'k')).toThrow(InvariantViolation); + }); + + test('the secret is reachable only through credentialKey(); .name stays public', () => { + const credential = new NameKeyCredential('x-api-key', 'secret'); + expect(credentialKey(credential)).toBe('secret'); + expect(credential.name).toBe('x-api-key'); + expect('key' in credential).toBe(false); + }); + + test('JSON.stringify reaches the name but never the key', () => { + const serialized = JSON.stringify( + new NameKeyCredential('x-api-key', 'super-secret'), + ); + expect(serialized).toContain('x-api-key'); + expect(serialized).not.toContain('super-secret'); + }); +}); diff --git a/packages/core/src/auth/credential.ts b/packages/core/src/auth/credential.ts new file mode 100644 index 0000000..f0581ee --- /dev/null +++ b/packages/core/src/auth/credential.ts @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/credential.ts +import {invariant} from '../invariant.js'; + +// No `as unique symbol` cast: TypeScript rejects `unique symbol` in a type assertion (TS1335). A +// `const` initialized directly by a `Symbol.for()` call already gets the `unique symbol` type, which +// is what makes it usable as a computed member name below. +const INSPECT: unique symbol = Symbol.for('nodejs.util.inspect.custom'); + +/** + * TypeScript has no friend classes, so {@link createBearerToken} -- a plain function, not a member -- + * reaches the private constructor through this module-scoped `let`, assigned exactly once inside the + * class's `static {}` block. Init-once wiring, not mutable state; the same shape every builder-based + * model in `src/http/` uses. + */ +let createToken: (token: string, expiresAt: number | undefined) => BearerToken; + +/** + * An OAuth2 bearer token (AUTH-8, AUTH-9, AUTH-10). + * + * A class with `#token`, not a frozen data object, for two reasons AUTH-8 and AUTH-9 make between + * them: + * + * - **Redaction.** AUTH-8 requires EVERY credential type to redact its secret in any + * string/diagnostic representation. A plain `{token, expiresAt}` object redacts nothing: + * `console.log` prints the token, `JSON.stringify` serializes it, and any structured logger walking + * the object graph carries it into a log sink. `#token` is unreachable to all three, and the + * `toString`/inspect pair below gives the redacted form those paths fall back to. `expiresAt` stays + * an ordinary public field -- AUTH-8 explicitly permits non-secret fields to remain visible. + * - **Nominality.** `TokenProvider` returns a `BearerToken`, so with a structural interface a provider + * could hand back an object literal and bypass AUTH-9's non-blank validation entirely. `#token` + * makes the type nominal and the `private` constructor makes {@link createBearerToken} the only way + * to build one, so the validation cannot be routed around. + * + * AUTH-8's VALUE equality is unaffected: it lives in {@link bearerTokensEqual}, a pure function over + * the two fields, exactly as it did when this was a data object. There is deliberately no `equals` + * member -- the key credentials below need reference identity, and keeping equality out of both + * classes keeps that distinction in one place. + * + * @public + */ +export class BearerToken { + readonly #token: string; + /** Epoch ms; `undefined` means "never locally expires" (AUTH-10). Non-secret, so visible. */ + readonly expiresAt: number | undefined; + + private constructor(token: string, expiresAt: number | undefined) { + this.#token = token; + this.expiresAt = expiresAt; + Object.freeze(this); + } + + static { + createToken = (token, expiresAt) => new BearerToken(token, expiresAt); + } + + /** The opaque token, never blank (AUTH-9). Read by the stamping path that writes the header. */ + get token(): string { + return this.#token; + } + + /** + * AUTH-8's redacted string form. The expiry survives; the token does not. + * + * @returns the representation with the token masked. + */ + toString(): string { + return `BearerToken{token=***, expiresAt=${String(this.expiresAt)}}`; + } + + /** + * The same redaction for `console.log`/`util.inspect`, which do not route object arguments through + * `toString`. Node-specific but harmless elsewhere -- an unrecognized well-known symbol is simply + * never read. + * + * @returns the redacted representation. + */ + [INSPECT](): string { + return this.toString(); + } +} + +/** + * Builds a frozen {@link BearerToken}. The only way to construct one: the constructor is `private`, so + * AUTH-9's validation below cannot be bypassed by a provider returning a hand-built value. + * + * @param token - the opaque token. Must not be blank. + * @param expiresAt - epoch ms at which the token expires, or `undefined` for "never locally expires". + * When present it must be a finite number. + * @returns the frozen token. + * @throws InvariantViolation when `token` is blank (AUTH-9), or when `expiresAt` is present but not + * finite -- both caller misconfigurations. + * + * @public + */ +export function createBearerToken( + token: string, + expiresAt?: number, +): BearerToken { + invariant(token.trim().length > 0, 'bearer token must not be blank'); // AUTH-9 + // A `NaN` expiry makes every comparison in `isBearerTokenExpired` false, so the token reads as + // permanently fresh and the cache stamps a dead credential forever -- silently, and with no + // provider call to notice. Rejected at construction for the same reason `authStep` rejects a + // non-finite margin: it is the identical bug arriving through the other door. + invariant( + expiresAt === undefined || Number.isFinite(expiresAt), + `bearer token expiresAt must be a finite epoch, got ${String(expiresAt)}`, + ); + return createToken(token, expiresAt); +} + +/** + * AUTH-8's VALUE equality for {@link BearerToken}, over the real token and expiry. + * + * A pure function rather than an `equals` member, deliberately: the two key credentials next door + * need REFERENCE identity, and keeping equality out of every credential class is what stops one of + * them acquiring value semantics by accident. The redacted string form has no bearing on it. + * + * @param a - the left token. + * @param b - the right token. + * @returns `true` when token and expiry both match. + * + * @public + */ +export function bearerTokensEqual(a: BearerToken, b: BearerToken): boolean { + return a.token === b.token && a.expiresAt === b.expiresAt; +} + +/** + * AUTH-10: expired at `nowMs` with grace margin `marginMs` if and only if an expiry is set and + * `nowMs + marginMs` strictly exceeds it. An exact hit on the expiry instant is NOT yet expired. + * + * @param token - the token to test. + * @param nowMs - the reference time, epoch ms. + * @param marginMs - the refresh grace margin in ms; `0` evaluates true expiry. + * @returns `true` when the token is expired under that margin. + * + * @internal + */ +export function isBearerTokenExpired( + token: BearerToken, + nowMs: number, + marginMs: number, +): boolean { + return token.expiresAt !== undefined && nowMs + marginMs > token.expiresAt; +} + +/** + * The friend-class hooks for the two key credentials' secrets. `static-key.ts` -- a different module, + * and the ONLY sanctioned reader -- reaches them through {@link credentialKey}. + * + * A public `get key()` would have been simpler and was the first shape here, but it re-opens exactly + * the leak the `#key` note below argues against: it puts the secret back on the published `.d.ts`, + * reachable as `credential.key` by any consumer, any diagnostic helper walking accessors, and any + * future logging step. Init-once wiring assigned in each class's `static {}` block, not mutable state. + */ +let readApiKey: (credential: ApiKeyCredential) => string; +let readNameKey: (credential: NameKeyCredential) => string; + +/** + * The in-package read hook for a static key credential's secret (AUTH-26's stamping path). + * + * Exported (still internal-only, absent from the package barrel) because the one caller -- + * `stampStaticKey` -- lives in another module and TypeScript has no friend-class visibility to + * express that with. + * + * @param credential - the credential whose secret is being stamped. + * @returns the raw key. + * + * @internal + */ +export function credentialKey( + credential: ApiKeyCredential | NameKeyCredential, +): string { + return credential instanceof ApiKeyCredential + ? readApiKey(credential) + : readNameKey(credential); +} + +/** + * A static API key (AUTH-8, AUTH-9, AUTH-26). + * + * AUTH-8 requires REFERENCE equality here — "two instances with identical fields are NOT equal" — so + * this is a class with a private field and deliberately NO `equals` override: `===`, the language + * default, already gives exactly those semantics. + * + * `#key`, not `private key`, is the deliberate exception to `docs/knowledge/data-modeling.md`'s + * `private`-by-default rule, and the same note requires the justification be written down: AUTH-8's + * redaction is a RUNTIME-privacy requirement, not a compile-time one. `private` is erased, leaving the + * secret reachable through `credential['key']`, `Object.keys`, `JSON.stringify`, and a default + * `util.inspect` — exactly the accidental-leak paths the redacted `toString`/inspect exist to close. + * `#key` is genuinely unreachable, and the nominality it induces is load-bearing besides: it is what + * stops a caller substituting an object literal for a validated credential. + * + * @public + */ +export class ApiKeyCredential { + readonly #key: string; + + /** + * @param key - the secret key. Must not be blank. + * @throws InvariantViolation when `key` is blank (AUTH-9). + */ + constructor(key: string) { + invariant(key.trim().length > 0, 'ApiKeyCredential key must not be blank'); // AUTH-9 + this.#key = key; + } + + static { + readApiKey = credential => credential.#key; + } + + /** + * AUTH-8's redacted string form. + * + * @returns a fixed representation with the key masked. + */ + toString(): string { + return 'ApiKeyCredential{key=***}'; + } + + /** + * The same redaction for `console.log`/`util.inspect`, which do not route object arguments through + * `toString`. Node-specific but harmless elsewhere — an unrecognized well-known symbol is simply + * never read. + * + * @returns the redacted representation. + */ + [INSPECT](): string { + return this.toString(); + } +} + +/** + * A named static key — the header-name/secret pair AUTH-26 stamps (AUTH-8, AUTH-9). + * + * `#key` for the same runtime-privacy reason as {@link ApiKeyCredential}; `name` is non-secret, which + * AUTH-8 explicitly permits to stay visible, so it is an ordinary public field. + * + * @public + */ +export class NameKeyCredential { + /** The non-secret identifier — a header name, a key id. AUTH-8 permits this to stay visible. */ + readonly name: string; + readonly #key: string; + + /** + * @param name - the non-secret identifier. Must not be blank. + * @param key - the secret key. Must not be blank. + * @throws InvariantViolation when either is blank (AUTH-9). + */ + constructor(name: string, key: string) { + invariant( + name.trim().length > 0, + 'NameKeyCredential name must not be blank', + ); // AUTH-9 + invariant(key.trim().length > 0, 'NameKeyCredential key must not be blank'); // AUTH-9 + this.name = name; + this.#key = key; + } + + static { + readNameKey = credential => credential.#key; + } + + /** + * AUTH-8's redacted string form: the name survives, the key does not. + * + * @returns the representation with the key masked. + */ + toString(): string { + return `NameKeyCredential{name=${this.name}, key=***}`; + } + + /** + * The same redaction for `console.log`/`util.inspect`. See {@link ApiKeyCredential} for why both + * hooks are needed. + * + * @returns the redacted representation. + */ + [INSPECT](): string { + return this.toString(); + } +} + +/** + * AUTH-11: an async token source. A plain function type, no class. + * + * A throwing or rejecting provider propagates and is never cached — `bearer-cache.ts` simply does not + * catch around the call, so that falls out of the structure rather than needing an explicit branch. + * + * **A provider MUST carry its own deadline.** It takes NO parameters, deliberately -- not even an + * optional `{signal}` bag. AUTH-34 coalesces every concurrent caller racing on a missing or expiring + * token onto ONE fetch, so that fetch belongs to no single request: handing it one caller's signal + * would let a stranger's cancellation reject callers who never aborted, including a caller who + * supplied no signal at all, and would let a request that merely finished tear down a refresh other + * requests are joined to. `bearer-cache.ts` races each caller's own WAIT against that caller's own + * signal instead, which cancels the wait without cancelling the work. + * + * Since nothing can ever populate a signal parameter, there is no signal parameter -- a slot + * documented as never filled is worse than no slot, because a caller writes code against it and then + * wonders why cancelling does nothing. The consequence is that nothing outside the provider can bound + * the fetch, so the provider must bound itself: + * + * ```ts + * const provider: TokenProvider = () => fetchToken({signal: AbortSignal.timeout(5_000)}); + * ``` + * + * `docs/knowledge/concurrency-and-async.md`'s "every external I/O call must carry a deadline" is the + * rule this discharges; its "pass the caller's signal down to the I/O primitive" rule is the one + * deliberately not applied here, because the premise it rests on -- that the call owns the I/O -- is + * false for a coalesced fetch. Recorded in the phase checklist's Deviation Ledger. + * + * @public + */ +export type TokenProvider = () => Promise; diff --git a/packages/core/src/auth/descriptor.test.ts b/packages/core/src/auth/descriptor.test.ts new file mode 100644 index 0000000..67340e1 --- /dev/null +++ b/packages/core/src/auth/descriptor.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/descriptor.test.ts +// Exercises: AUTH-3 (non-empty, immutable, ordered; empty list rejected as a programmer error via +// invariant(), not a typed operational leaf -- see the plan's Global Constraints). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {createAuthRequirement} from './requirement.js'; + +describe('createAuthDescriptor', () => { + test('preserves requirement order', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('DIGEST'), + createAuthRequirement('BASIC'), + ]); + expect(descriptor.requirements.map(r => r.scheme)).toEqual([ + 'DIGEST', + 'BASIC', + ]); + }); + + test('allowsAnonymous is true iff any requirement is NO_AUTH', () => { + expect( + createAuthDescriptor([createAuthRequirement('NO_AUTH')]).allowsAnonymous, + ).toBe(true); + expect( + createAuthDescriptor([createAuthRequirement('BASIC')]).allowsAnonymous, + ).toBe(false); + expect( + createAuthDescriptor([ + createAuthRequirement('BASIC'), + createAuthRequirement('NO_AUTH'), + ]).allowsAnonymous, + ).toBe(true); + }); + + test('rejects an empty requirement list (AUTH-3) -- a programmer error, not AuthResolutionError', () => { + expect(() => createAuthDescriptor([])).toThrow(InvariantViolation); + }); + + test('is frozen, including the requirements array', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('BASIC')]); + expect(Object.isFrozen(descriptor)).toBe(true); + expect(Object.isFrozen(descriptor.requirements)).toBe(true); + }); + + test('defensively copies the requirement list', () => { + const requirements = [createAuthRequirement('BASIC')]; + const descriptor = createAuthDescriptor(requirements); + requirements.push(createAuthRequirement('NO_AUTH')); + expect(descriptor.requirements.length).toBe(1); + }); +}); diff --git a/packages/core/src/auth/descriptor.ts b/packages/core/src/auth/descriptor.ts new file mode 100644 index 0000000..3afa42e --- /dev/null +++ b/packages/core/src/auth/descriptor.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/descriptor.ts +import {invariant} from '../invariant.js'; +import type {AuthRequirement} from './requirement.js'; + +/** + * AUTH-3: a non-empty, immutable, ordered list of requirements in preference order. + * + * @public + */ +export interface AuthDescriptor { + /** The requirements, in preference order. Never empty. */ + readonly requirements: readonly AuthRequirement[]; + /** `true` if and only if some requirement's scheme is `NO_AUTH` (AUTH-3). */ + readonly allowsAnonymous: boolean; +} + +/** + * Builds a frozen {@link AuthDescriptor}, copying the requirement list so later caller-side mutation + * cannot reach the stored value (AUTH-3). + * + * An empty list is a PROGRAMMER error — a caller assembling zero requirements has a bug, not an + * operational failure — so it goes through `invariant()`, the same call 5a's `retrySettings()` and + * 5b's `redirectSettings()` made, rather than a typed error leaf. + * + * @param requirements - the requirements, in preference order. Must be non-empty. + * @returns the frozen descriptor. + * @throws InvariantViolation when `requirements` is empty (AUTH-3). + * + * @public + */ +export function createAuthDescriptor( + requirements: readonly AuthRequirement[], +): AuthDescriptor { + invariant( + requirements.length > 0, + 'AuthDescriptor requires at least one AuthRequirement', + ); + return Object.freeze({ + requirements: Object.freeze([...requirements]), + allowsAnonymous: requirements.some( + requirement => requirement.scheme === 'NO_AUTH', + ), + }); +} diff --git a/packages/core/src/auth/digest.test.ts b/packages/core/src/auth/digest.test.ts new file mode 100644 index 0000000..c43fd80 --- /dev/null +++ b/packages/core/src/auth/digest.test.ts @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/digest.test.ts +// Exercises: AUTH-15 (exactly {MD5, MD5-sess, SHA-256, SHA-256-sess}, qop=auth or absent, declines +// auth-int and unsupported algorithms), AUTH-16 (satisfiability: scheme/realm/nonce/qop/algorithm, +// and configured-preference order over wire order), AUTH-17 (HA1/HA2/response per RFC 7616/2069, +// verified against independently-computed vectors), AUTH-18/AUTH-19 (nonce count: starts at 1, +// increments only on nonce reuse, 8 lower-case hex digits, bounded and drained to the cap), +// AUTH-20 (client nonce from crypto.getRandomValues, >=128 bits), AUTH-21 (UTF-8 vs ISO-8859-1 by +// charset), AUTH-22 (quoting, and cnonce/nc/qop emitted only when qop negotiated), AUTH-25 +// (Authorization vs Proxy-Authorization is the CALLER's job -- stamp() returns only the value). +import {describe, expect, test} from 'bun:test'; +import fc from 'fast-check'; +import {InvariantViolation} from '../invariant.js'; +import type {Challenge, DigestUriContext} from './challenge.js'; +import { + NonceCountStore, + computeDigestResponse, + digestHandler, +} from './digest.js'; + +const REALM = 'testrealm@host.com'; +const NONCE = 'dcd98b7102dd2f0e8b11d0f600bfb0c093'; +const CNONCE = '0a4f113b'; +const NC = '00000001'; +const BASE = { + realm: REALM, + nonce: NONCE, + isUtf8: true, + method: 'GET', + uri: '/dir/index.html', + username: 'Mufasa', + password: 'Circle Of Life', + cnonce: CNONCE, + nc: NC, +} as const; + +const REQUEST_CONTEXT: DigestUriContext = { + method: 'GET', + requestTarget: '/dir/index.html', +}; + +function digestChallenge(params: Record): Challenge { + return {scheme: 'digest', params: new Map(Object.entries(params))}; +} + +/** + * Captures a rejection reason. `expect(...).rejects` is typed as returning `void` under this runner's + * type definitions, so awaiting it trips `@typescript-eslint/await-thenable`; this helper keeps the + * assertion honest without a lint suppression. Same shape 5a's and 5b's step suites settled on. + */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +describe('computeDigestResponse (verified against RFC 2617/7616 vectors)', () => { + test('MD5, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: true, + }), + ).toBe('6629fae49393a05397450978507c4ef1'); + }); + + test('MD5, no qop (RFC 2069 form)', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: false, + }), + ).toBe('670fd8c2df070c60b045671b8b24ff02'); + }); + + test('MD5-sess, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'MD5-sess', + hasQopAuth: true, + }), + ).toBe('8e3825c57e897f5a0dec6c2d4e5059d0'); + }); + + test('SHA-256, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'SHA-256', + hasQopAuth: true, + }), + ).toBe('5abdd07184ba512a22c53f41470e5eea7dcaa3a93a59b630c13dfe0a5dc6e38b'); + }); + + test('SHA-256-sess, qop=auth', async () => { + expect( + await computeDigestResponse({ + ...BASE, + algorithm: 'SHA-256-sess', + hasQopAuth: true, + }), + ).toBe('b8822e12417cb7750f4e2b8515f0dcf25b7dd26993e80bee1426201446a7f59b'); + }); +}); + +describe('computeDigestResponse charset and determinism (AUTH-17/AUTH-21)', () => { + test('AUTH-21: the charset changes the hash for a non-ASCII password', async () => { + const utf8 = await computeDigestResponse({ + ...BASE, + password: 'pässwörd', + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: true, + }); + const latin1 = await computeDigestResponse({ + ...BASE, + password: 'pässwörd', + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: false, + }); + expect(utf8).not.toBe(latin1); + }); + + test('AUTH-21: an all-ASCII input hashes identically under either charset', async () => { + const utf8 = await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: true, + }); + const latin1 = await computeDigestResponse({ + ...BASE, + algorithm: 'MD5', + hasQopAuth: true, + isUtf8: false, + }); + expect(utf8).toBe(latin1); + }); + + test('is deterministic -- the same inputs recompute to the same response (AUTH-17)', async () => { + const input = {...BASE, algorithm: 'SHA-256', hasQopAuth: true} as const; + expect(await computeDigestResponse(input)).toBe( + await computeDigestResponse(input), + ); + }); +}); + +describe('NonceCountStore (AUTH-18/19)', () => { + test('starts at 1 for a first-seen nonce', () => { + expect(new NonceCountStore().next('n1')).toBe(1); + }); + + test('increments only on reuse of the SAME nonce', () => { + const store = new NonceCountStore(); + expect(store.next('n1')).toBe(1); + // A different nonce starts fresh; it does not inherit n1's count. + expect(store.next('n2')).toBe(1); + expect(store.next('n1')).toBe(2); + expect(store.next('n1')).toBe(3); + }); + + test('property: a fixed nonce produces a strictly increasing sequence', () => { + fc.assert( + fc.property(fc.integer({min: 1, max: 200}), calls => { + const fresh = new NonceCountStore(); + let previous = 0; + for (let i = 0; i < calls; i += 1) { + const count = fresh.next('fixed'); + expect(count).toBeGreaterThan(previous); + previous = count; + } + }), + ); + }); + + test('bounded at 1024 entries, oldest evicted first (AUTH-19)', () => { + const store = new NonceCountStore(); + for (let i = 0; i < 1024; i += 1) store.next(`nonce-${String(i)}`); + store.next('nonce-1024'); // 1025th distinct nonce -- evicts 'nonce-0' + expect(store.next('nonce-0')).toBe(1); // evicted -- starts over, not 2 + }); + + test('drains back UNDER the cap after every admit, not one victim per insert (AUTH-19/XCUT-14)', () => { + // The distinguishing case for drain-to-cap vs pre-insert check-then-evict: a long run of fresh + // server-chosen nonces. A single-victim-per-insert store stays pinned at (or above) the bound + // forever without converging; the loop must leave the map at exactly the cap after each admit. + const store = new NonceCountStore(); + for (let i = 0; i < 4096; i += 1) { + store.next(`burst-${String(i)}`); + expect(store.size).toBeLessThanOrEqual(1024); + } + expect(store.size).toBe(1024); + }); +}); + +describe('digestHandler: credential validation (AUTH-9/AUTH-22)', () => { + test('rejects blank credentials', () => { + expect(() => digestHandler('', 'p')).toThrow(InvariantViolation); + expect(() => digestHandler('u', ' ')).toThrow(InvariantViolation); + }); + + test('rejects a username that is not header-safe (AUTH-22)', () => { + // AUTH-22 writes the username verbatim into the Authorization value, and HTTP-18 admits only + // HTAB plus printable ASCII there. Caller configuration, so it fails fast and loudly at + // construction rather than being declined silently per request the way a server realm is. + // RFC 7616 §4's `username*` encoding would lift this and is deferred. + expect(() => digestHandler('björn', 'p')).toThrow('header-safe'); + }); + + test('canHandle declines a challenge whose realm cannot be echoed (AUTH-22/HTTP-18)', () => { + // A received field-value may legally carry obs-text (HTTP-19), so `realm="café"` reaches us + // intact -- but it cannot go back out. Declining makes AUTH-33 surface the 401 unchanged, which + // beats building the header anyway and throwing HeaderValidationError out of the whole step. + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: 'café', nonce: NONCE, algorithm: 'MD5'}), + ), + ).toBe(false); + }); + + test('canHandle declines an opaque or nonce that cannot be echoed (AUTH-22/HTTP-18)', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, opaque: 'ö'}), + ), + ).toBe(false); + expect( + handler.canHandle(digestChallenge({realm: REALM, nonce: 'nö'})), + ).toBe(false); + }); +}); + +describe('digestHandler: challenge selection (AUTH-15/AUTH-16)', () => { + test('canHandle accepts a well-formed Digest challenge', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth'}), + ), + ).toBe(true); + }); + + test('canHandle accepts a qop list that merely CONTAINS auth', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth-int, auth'}), + ), + ).toBe(true); + }); + + test('canHandle rejects a non-Digest scheme', () => { + expect( + digestHandler('u', 'p').canHandle({scheme: 'basic', params: new Map()}), + ).toBe(false); + }); + + test('canHandle rejects a missing realm or nonce', () => { + const handler = digestHandler('u', 'p'); + expect(handler.canHandle(digestChallenge({nonce: NONCE}))).toBe(false); + expect(handler.canHandle(digestChallenge({realm: REALM}))).toBe(false); + }); + + test('canHandle declines an auth-int-only qop (AUTH-15)', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth-int'}), + ), + ).toBe(false); + }); + + test('canHandle declines an unsupported algorithm (AUTH-15)', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'MD4'}), + ), + ).toBe(false); + }); + + test('canHandle matches the algorithm name case-insensitively', () => { + const handler = digestHandler('u', 'p'); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'sha-256'}), + ), + ).toBe(true); + }); +}); + +describe('digestHandler stamping (AUTH-17..AUTH-22, AUTH-25)', () => { + test('canHandle defaults to MD5 when algorithm is absent', () => { + const handler = digestHandler('u', 'p', {algorithmPreference: ['MD5']}); + expect( + handler.canHandle(digestChallenge({realm: REALM, nonce: NONCE})), + ).toBe(true); + }); + + test('canHandle honors a caller-restricted algorithm preference', () => { + const handler = digestHandler('u', 'p', {algorithmPreference: ['SHA-256']}); + expect( + handler.canHandle( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'MD5'}), + ), + ).toBe(false); + }); + + test('rank reflects preference-list order, for composing-handler.ts to sort by', () => { + const handler = digestHandler('u', 'p', { + algorithmPreference: ['SHA-256', 'MD5'], + }); + const sha = handler.rank?.( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'SHA-256'}), + ); + const md5Rank = handler.rank?.( + digestChallenge({realm: REALM, nonce: NONCE, algorithm: 'MD5'}), + ); + expect(sha).toBeLessThan(md5Rank ?? Number.POSITIVE_INFINITY); + }); + + test('rank is worst-possible for a challenge it cannot handle', () => { + const handler = digestHandler('u', 'p'); + expect(handler.rank?.({scheme: 'basic', params: new Map()})).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + test('stamp() produces a well-formed Digest header value, qop negotiated', async () => { + const handler = digestHandler('Mufasa', 'Circle Of Life'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const value = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(value.startsWith('Digest ')).toBe(true); + expect(value).toContain('username="Mufasa"'); + expect(value).toContain(`realm="${REALM}"`); + expect(value).toContain('uri="/dir/index.html"'); + expect(value).toContain('qop=auth'); + expect(value).toMatch(/nc=[0-9a-f]{8}/u); + expect(value).toMatch(/response="[0-9a-f]+"/u); + }); +}); + +describe('digestHandler nonce counting and preconditions (AUTH-18/AUTH-25)', () => { + test('stamp() draws a fresh >=128-bit client nonce per call (AUTH-20)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const first = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, REQUEST_CONTEXT), + ); + const second = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, REQUEST_CONTEXT), + ); + expect(first?.[1]).toHaveLength(32); // 16 bytes rendered as hex + expect(first?.[1]).not.toBe(second?.[1]); + }); + + test('stamp() emits the FULL algorithm spelling, unquoted (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const value = await handler.stamp( + digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + algorithm: 'SHA-256-sess', + }), + REQUEST_CONTEXT, + ); + expect(value).toContain('algorithm=SHA-256-sess'); + }); + + test('stamp() escapes a quote inside a realm rather than emitting it raw (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const value = await handler.stamp( + digestChallenge({realm: 'a"b', nonce: NONCE}), + REQUEST_CONTEXT, + ); + expect(value).toContain(String.raw`realm="a\"b"`); + }); +}); + +describe('digestHandler opaque and qop emission (AUTH-22)', () => { + test('stamp() echoes the challenge opaque back, quoted (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + opaque: '5ccc069c403ebaf9f0171e9517f40e41', + }); + const value = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(value).toContain('opaque="5ccc069c403ebaf9f0171e9517f40e41"'); + }); + + test('stamp() omits opaque entirely when the challenge carried none (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const value = await handler.stamp( + digestChallenge({realm: REALM, nonce: NONCE}), + REQUEST_CONTEXT, + ); + expect(value).not.toContain('opaque'); + }); +}); + +describe('digestHandler nonce-count sequencing (AUTH-18)', () => { + test('stamp() omits cnonce/nc/qop when the challenge negotiated no qop (AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({realm: REALM, nonce: NONCE}); + const value = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(value).not.toContain('qop='); + expect(value).not.toContain('cnonce='); + expect(value).not.toContain('nc='); + }); + + test('two successive stamp() calls against the SAME nonce increment nc (AUTH-18)', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const first = await handler.stamp(challenge, REQUEST_CONTEXT); + const second = await handler.stamp(challenge, REQUEST_CONTEXT); + expect(first).toContain('nc=00000001'); + expect(second).toContain('nc=00000002'); + }); + + test('a no-qop stamp does not consume a nonce count (AUTH-18/AUTH-22)', async () => { + const handler = digestHandler('u', 'p'); + await handler.stamp( + digestChallenge({realm: REALM, nonce: NONCE}), + REQUEST_CONTEXT, + ); + const withQop = await handler.stamp( + digestChallenge({realm: REALM, nonce: NONCE, qop: 'auth'}), + REQUEST_CONTEXT, + ); + expect(withQop).toContain('nc=00000001'); + }); + + test('stamp() rejects a challenge canHandle() would decline', async () => { + const handler = digestHandler('u', 'p'); + expect( + await rejectionOf( + handler.stamp({scheme: 'basic', params: new Map()}, REQUEST_CONTEXT), + ), + ).toBeInstanceOf(InvariantViolation); + }); + + test('stamp() rejects a missing DigestUriContext -- it cannot compute HA2 without one', async () => { + const handler = digestHandler('u', 'p'); + expect( + await rejectionOf( + handler.stamp(digestChallenge({realm: REALM, nonce: NONCE})), + ), + ).toBeInstanceOf(InvariantViolation); + }); +}); diff --git a/packages/core/src/auth/digest.ts b/packages/core/src/auth/digest.ts new file mode 100644 index 0000000..93ffddd --- /dev/null +++ b/packages/core/src/auth/digest.ts @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/digest.ts +import {hasForbiddenOutboundByte} from '../http/ascii-validation.js'; +import {invariant} from '../invariant.js'; +import type { + Challenge, + ChallengeHandler, + DigestUriContext, +} from './challenge.js'; +import {md5, toHex} from './md5.js'; + +/** + * AUTH-15: exactly these four algorithms are supported. `auth-int` and every other algorithm is + * declined rather than approximated. + * + * @public + */ +export type DigestAlgorithm = 'MD5' | 'MD5-sess' | 'SHA-256' | 'SHA-256-sess'; + +// `as const`, not a bare `readonly` annotation, so the CONSTANT_CASE is honest: `naming-conventions.md` +// reserves that casing for deeply immutable values, and a bare `readonly DigestAlgorithm[]` annotation +// is a compile-time claim only -- the array stays mutable at runtime through a cast. +const SUPPORTED_ALGORITHMS = [ + 'MD5', + 'MD5-sess', + 'SHA-256', + 'SHA-256-sess', +] as const satisfies readonly DigestAlgorithm[]; + +// Strongest first. AUTH-16 makes this list the PREFERENCE order, applied regardless of the order the +// server offered its challenges in. +const DEFAULT_ALGORITHM_PREFERENCE = [ + 'SHA-256-sess', + 'SHA-256', + 'MD5-sess', + 'MD5', +] as const satisfies readonly DigestAlgorithm[]; + +const NONCE_COUNT_LIMIT = 1024; + +/** + * Digest handler tuning. + * + * @internal + */ +export interface DigestOptions { + /** + * Preferred-first order, and also the ACCEPTABLE set: an algorithm absent from this list is + * declined outright (AUTH-16). Defaults to `['SHA-256-sess', 'SHA-256', 'MD5-sess', 'MD5']`. + */ + readonly algorithmPreference?: readonly DigestAlgorithm[] | undefined; +} + +function baseAlgorithm(algorithm: DigestAlgorithm): 'MD5' | 'SHA-256' { + return algorithm.startsWith('MD5') ? 'MD5' : 'SHA-256'; +} + +/** + * AUTH-21's non-UTF-8 branch. ISO-8859-1 is a byte-for-byte code-unit copy for every character it can + * represent; a character outside the codebook has no ISO-8859-1 encoding at all, and truncating is the + * same lossy answer every other Latin-1 encoder gives. A server that expects such a character is + * required to advertise `charset=UTF-8`, which routes to the other branch. + */ +function encodeLatin1(input: string): Uint8Array { + const bytes = new Uint8Array(input.length); + for (let i = 0; i < input.length; i += 1) bytes[i] = input.charCodeAt(i); + return bytes; +} + +/** + * AUTH-21's UTF-8 branch, copied into a freshly-allocated buffer. + * + * The copy is not incidental: `crypto.subtle.digest` takes a `BufferSource`, which excludes a view + * that might sit on a `SharedArrayBuffer`, and `TextEncoder.encode` is typed as the wider + * `Uint8Array`. Re-narrowing with a cast would assert something the type system + * cannot check; allocating an exact-typed buffer costs one copy of a string that is never more than a + * few hundred bytes. + */ +function encodeUtf8(input: string): Uint8Array { + const encoded = new TextEncoder().encode(input); + const bytes = new Uint8Array(encoded.length); + bytes.set(encoded); + return bytes; +} + +/** AUTH-17/AUTH-21: one hash, over one encoding of one string. */ +interface HashInput { + readonly base: 'MD5' | 'SHA-256'; + readonly input: string; + /** AUTH-21: UTF-8 when the challenge advertised `charset=UTF-8`, ISO-8859-1 otherwise. */ + readonly isUtf8: boolean; +} + +// An options object rather than three positional parameters: `function-design.md` requires one at +// three or more parameters, and unconditionally for any boolean parameter -- `hashHex(base, s, true)` +// says nothing at the call site about what the `true` selects. +async function hashHex({base, input, isUtf8}: HashInput): Promise { + const bytes = isUtf8 ? encodeUtf8(input) : encodeLatin1(input); + // MD5 is hand-rolled because Web Crypto excludes it; SHA-256 goes through Web Crypto rather than + // `node:crypto` to keep the package portable (SEAM-1, `sdk-design-nodejs/06`). + if (base === 'MD5') return toHex(md5(bytes)); + return toHex( + new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)), + ); +} + +/** + * The per-server-nonce request counter (AUTH-18, AUTH-19). + * + * `nc` starts at 1 for a first-seen nonce, increments only on reuse of that same nonce, and wraps to + * the low 32 bits on overflow. Bounded at 1024 entries with insertion-order eviction — `Map` + * iteration order IS insertion order, so the oldest key is `keys().next().value` and no separate LRU + * structure is needed. Evicting a live nonce is harmless: its count restarts at 1, which is + * spec-legal for a nonce the server has just re-issued. + * + * The eviction is an insert-THEN-drain, never a pre-insert check-then-evict: `next()` admits the + * nonce first and only then brings the map back under the cap, which is how + * `docs/knowledge/concurrency-and-async.md` (XCUT-14) and AUTH-19 both word it — "drained back under + * the cap after admitting a nonce". The key space is the SERVER's, since it picks the nonces, so a + * pre-insert evict would leave a burst sitting above the cap rather than converging to it. + * + * The drain is a `while` rather than an `if` purely as defence, and the distinction is NOT currently + * observable: `next()` grows the map by at most one entry per call, so the body can run at most once + * and the two spellings are equivalent today. The loop is what keeps the bound true if `NONCE_COUNT_LIMIT` + * is ever lowered at runtime or a second writer is ever added. + * + * AUTH-24's concurrency clause: `next()` is one synchronous read-increment-write with no `await` + * between the read and the write, so two concurrent callers cannot observe the same count. Node and + * Bun have no preemptive interleaving mid-statement — the same collapse 5a documented for BODY-3's + * materialize-once guard. + * + * @internal + */ +export class NonceCountStore { + private readonly counts = new Map(); + + /** + * The number of nonces currently tracked. + * + * Exposed so the bound itself is assertable — otherwise "drained back under the cap" is testable + * only through the indirect "an evicted nonce restarts at 1" probe, which passes for a + * single-victim-per-insert store that never converges. + */ + get size(): number { + return this.counts.size; + } + + /** + * Returns the nonce count to send with this request (AUTH-18). + * + * @param nonce - the server-chosen nonce being answered. + * @returns `1` the first time this nonce is seen, one more than the previous value on each reuse, + * wrapping to the low 32 bits on overflow. + */ + next(nonce: string): number { + const current = this.counts.get(nonce); + const count = current === undefined ? 1 : (current + 1) >>> 0; + this.counts.set(nonce, count); + + // The just-admitted nonce sits at the TAIL of insertion order (a `set` on an existing key leaves + // the map's size unchanged, so the loop is not entered at all on a reuse), which is why draining + // from the head can never evict the live nonce this call is answering with. + while (this.counts.size > NONCE_COUNT_LIMIT) { + const oldest = this.counts.keys().next().value; + invariant( + oldest !== undefined, + 'nonce-count store is over its bound but reports no oldest entry', + ); + this.counts.delete(oldest); + } + + return count; + } +} + +/** AUTH-18: exactly 8 lower-case hex digits. */ +function formatNonceCount(count: number): string { + return count.toString(16).padStart(8, '0'); +} + +/** AUTH-20: at least 128 bits from a CSPRNG. Never `Math.random()`. */ +function generateClientNonce(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return toHex(bytes); +} + +interface ParsedDigestChallenge { + readonly algorithm: DigestAlgorithm; + readonly realm: string; + readonly nonce: string; + /** + * Whether `qop=auth` was negotiated. Named for what it HOLDS, not for the wire parameter: `qop` on + * the wire is a string (`auth`, `auth-int`), so a boolean called `qop` reads as that value. + */ + readonly hasQopAuth: boolean; + readonly isUtf8: boolean; + /** + * AUTH-22 names `opaque` in the must-quote list, which only makes sense if it is emitted: RFC 7616 + * requires the client return the server's opaque value unchanged, and a server that binds session + * state to it rejects a request that omits it. Absent when the challenge carried none. + */ + readonly opaque: string | undefined; +} + +/** + * AUTH-22 echoes `realm`, `nonce`, and `opaque` VERBATIM into the `Authorization` value, and HTTP-18's + * outbound grammar admits only HTAB plus printable ASCII. A received challenge is held to the laxer + * inbound rule (HTTP-19 permits obs-text, exactly so a Latin-1 field is not silently dropped), so a + * server may legitimately hand us a realm this client cannot echo -- `Digest realm="café"` is a real + * RFC 7616 shape, which is why the spec has a `charset` parameter at all. + * + * Declining such a challenge makes `canHandle` false, so the composer finds no candidate and AUTH-33 + * surfaces the 401 unchanged. That is strictly better than the alternative it replaces, which was to + * build the header anyway and throw `HeaderValidationError` out of the whole auth step -- turning a + * challenge the caller could have inspected into an exception. Relaxing the outbound rule instead was + * never an option: HTTP-17/18/19's strictness is the request-splitting defence. + * + * The consequence is that AUTH-21's UTF-8 branch is reachable for the HASH INPUT (where a non-ASCII + * password lives and works) but not for the realm ECHO. RFC 7616 §4's `username*` (RFC 5987) extended + * notation is the standard answer and is deferred; both are recorded in the Deviation Ledger. + */ +function isHeaderSafeEcho(info: { + readonly realm: string; + readonly nonce: string; + readonly opaque: string | undefined; +}): boolean { + return ( + !hasForbiddenOutboundByte(info.realm) && + !hasForbiddenOutboundByte(info.nonce) && + (info.opaque === undefined || !hasForbiddenOutboundByte(info.opaque)) + ); +} + +/** + * AUTH-16: satisfiable if and only if the scheme is `digest`, `realm` and `nonce` are both present, + * `qop` is absent or contains `auth`, the algorithm (defaulting to `MD5`) is in the caller's + * configured preference list, and every field AUTH-22 echoes back is header-safe + * ({@link isHeaderSafeEcho}). + */ +function parseDigestChallenge( + challenge: Challenge, + preference: readonly DigestAlgorithm[], +): ParsedDigestChallenge | undefined { + if (challenge.scheme !== 'digest') return undefined; + const realm = challenge.params.get('realm'); + const nonce = challenge.params.get('nonce'); + if (realm === undefined || nonce === undefined) return undefined; + + const qopRaw = challenge.params.get('qop'); + const hasQop = qopRaw !== undefined; + // AUTH-15: an `auth-int`-only challenge is DECLINED, not silently downgraded. + if ( + hasQop && + !qopRaw.split(',').some(entry => entry.trim().toLowerCase() === 'auth') + ) { + return undefined; + } + + const algorithmRaw = challenge.params.get('algorithm'); + const algorithm = + algorithmRaw === undefined + ? 'MD5' + : SUPPORTED_ALGORITHMS.find( + candidate => candidate.toLowerCase() === algorithmRaw.toLowerCase(), + ); + if (algorithm === undefined || !preference.includes(algorithm)) { + return undefined; + } + + const isUtf8 = + (challenge.params.get('charset') ?? '').toLowerCase() === 'utf-8'; + const info: ParsedDigestChallenge = { + algorithm, + realm, + nonce, + hasQopAuth: hasQop, + isUtf8, + opaque: challenge.params.get('opaque'), + }; + return isHeaderSafeEcho(info) ? info : undefined; +} + +/** + * Everything {@link computeDigestResponse} needs. Bundled into one object because the computation + * genuinely takes eleven inputs and `max-params` is 3. + * + * @internal + */ +export interface DigestComputationInput { + /** The negotiated algorithm; `-sess` variants fold the nonce and cnonce into HA1. */ + readonly algorithm: DigestAlgorithm; + /** The challenge's realm. */ + readonly realm: string; + /** The server-chosen nonce. */ + readonly nonce: string; + /** Whether `qop=auth` was negotiated. `false` selects RFC 2069's shorter response input. */ + readonly hasQopAuth: boolean; + /** AUTH-21: UTF-8 hash input when the challenge advertised `charset=UTF-8`, ISO-8859-1 otherwise. */ + readonly isUtf8: boolean; + /** The request method. */ + readonly method: string; + /** The digest-uri: the request-target. */ + readonly uri: string; + /** The user id. */ + readonly username: string; + /** The password. */ + readonly password: string; + /** The client nonce; ignored when `qop` is `false`. */ + readonly cnonce: string; + /** The 8-hex-digit nonce count; ignored when `qop` is `false`. */ + readonly nc: string; +} + +/** + * Computes HA1, HA2, and the Digest response per RFC 7616/2069 (AUTH-17), in lower-case hex. + * + * Exported, and taking a single bundled parameter, so it can be unit-tested directly against fixed, + * independently-verified vectors: `digestHandler()`'s own `stamp()` always generates a fresh random + * cnonce (AUTH-20), so its output can never be asserted against a fixed expected hash end-to-end. + * + * @param input - the full computation input. + * @returns the response value, lower-case hex. + * + * @internal + */ +export async function computeDigestResponse( + input: DigestComputationInput, +): Promise { + const base = baseAlgorithm(input.algorithm); + const isUtf8 = input.isUtf8; + const ha1Plain = await hashHex({ + base, + input: `${input.username}:${input.realm}:${input.password}`, + isUtf8, + }); + const ha1 = input.algorithm.endsWith('-sess') + ? await hashHex({ + base, + input: `${ha1Plain}:${input.nonce}:${input.cnonce}`, + isUtf8, + }) + : ha1Plain; + const ha2 = await hashHex({ + base, + input: `${input.method}:${input.uri}`, + isUtf8, + }); + const responseInput = input.hasQopAuth + ? `${ha1}:${input.nonce}:${input.nc}:${input.cnonce}:auth:${ha2}` + : `${ha1}:${input.nonce}:${ha2}`; + return hashHex({base, input: responseInput, isUtf8}); +} + +/** AUTH-22's quoting: a quoted-string with `\` and `"` escaped. */ +function quote(value: string): string { + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; +} + +interface HeaderValueParams { + readonly username: string; + readonly info: ParsedDigestChallenge; + readonly uri: string; + readonly response: string; + readonly cnonce: string; + readonly nc: string; +} + +/** + * AUTH-22: quotes `username`/`realm`/`nonce`/`uri`/`response`/`cnonce`/`opaque`; leaves + * `qop`/`nc`/`algorithm` unquoted, with the full algorithm spelling; emits `cnonce`/`nc`/`qop` only + * when `qop` was actually negotiated. + */ +function buildHeaderValue(params: HeaderValueParams): string { + const {username, info, uri, response, cnonce, nc} = params; + const parts = [ + `username=${quote(username)}`, + `realm=${quote(info.realm)}`, + `nonce=${quote(info.nonce)}`, + `uri=${quote(uri)}`, + `algorithm=${info.algorithm}`, + `response=${quote(response)}`, + ]; + // AUTH-22: `opaque` is quoted and echoed back verbatim when the challenge carried one. + if (info.opaque !== undefined) parts.push(`opaque=${quote(info.opaque)}`); + if (info.hasQopAuth) + parts.push('qop=auth', `nc=${nc}`, `cnonce=${quote(cnonce)}`); + return `Digest ${parts.join(', ')}`; +} + +/** + * The Digest challenge handler (AUTH-15–AUTH-22). + * + * Cryptographic primitives are split across two sources for portability: `md5.ts` for MD5/MD5-sess, + * which Web Crypto deliberately excludes, and `crypto.subtle.digest('SHA-256', …)` for the SHA-256 + * pair. The client nonce comes from `crypto.getRandomValues()` (AUTH-20). + * + * The per-nonce counter is the one piece of mutable state, and it needs no lock: nothing awaits + * between its read and its write (AUTH-24). + * + * Challenge-reactive only — Digest structurally cannot stamp before seeing the server's + * `realm`/`nonce`. + * + * @param username - the user id. Must not be blank, and must be header-safe (printable ASCII): + * AUTH-22 writes it into the `Authorization` value verbatim. + * @param password - the password. Must not be blank. May hold any character — it only ever reaches + * the hash input, which is where AUTH-21's UTF-8/Latin-1 choice applies. + * @param options - algorithm preference; omitted means strongest-first over all four. + * @returns a handler that answers satisfiable `digest` challenges. + * @throws InvariantViolation when either credential is blank, or the username carries a byte HTTP-18 + * forbids in an outbound header value — both caller misconfigurations. + * + * @internal + */ +export function digestHandler( + username: string, + password: string, + options?: DigestOptions, +): ChallengeHandler { + invariant(username.trim().length > 0, 'Digest username must not be blank'); + invariant(password.trim().length > 0, 'Digest password must not be blank'); + // Configuration, not wire data: a non-ASCII username is the caller's own mistake, so it fails fast + // and loudly at construction rather than being declined silently per-request the way an + // unechoable server realm is. RFC 7616 §4's `username*` encoding would lift this and is deferred. + invariant( + !hasForbiddenOutboundByte(username), + 'Digest username must be header-safe (printable ASCII); RFC 7616 username* encoding is not yet supported', + ); + const preference = + options?.algorithmPreference ?? DEFAULT_ALGORITHM_PREFERENCE; + const nonceCounts = new NonceCountStore(); + + return { + canHandle: (challenge: Challenge): boolean => + parseDigestChallenge(challenge, preference) !== undefined, + + // AUTH-16: among several Digest challenges differing only by algorithm, prefer the one earliest + // in the CONFIGURED list, not the one earliest on the wire. + rank: (challenge: Challenge): number => { + const parsed = parseDigestChallenge(challenge, preference); + return parsed === undefined + ? Number.MAX_SAFE_INTEGER + : preference.indexOf(parsed.algorithm); + }, + + stamp: async ( + challenge: Challenge, + request?: DigestUriContext, + ): Promise => { + const info = parseDigestChallenge(challenge, preference); + invariant( + info !== undefined, + 'digestHandler.stamp called with a challenge canHandle() would reject', + ); + invariant( + request !== undefined, + 'digestHandler.stamp requires a DigestUriContext (method + requestTarget)', + ); + + const cnonce = generateClientNonce(); + const nc = info.hasQopAuth + ? formatNonceCount(nonceCounts.next(info.nonce)) + : ''; + const response = await computeDigestResponse({ + algorithm: info.algorithm, + realm: info.realm, + nonce: info.nonce, + hasQopAuth: info.hasQopAuth, + isUtf8: info.isUtf8, + method: request.method, + uri: request.requestTarget, + username, + password, + cnonce, + nc, + }); + return buildHeaderValue({ + username, + info, + uri: request.requestTarget, + response, + cnonce, + nc, + }); + }, + }; +} diff --git a/packages/core/src/auth/errors.test.ts b/packages/core/src/auth/errors.test.ts new file mode 100644 index 0000000..3de4668 --- /dev/null +++ b/packages/core/src/auth/errors.test.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/errors.test.ts +// Exercises: AUTH-6 (the resolution error names required and available schemes, and copies both +// lists onto its own frozen fields), AUTH-28 (the plaintext guard names the step and scheme), +// AUTH-35 (the resolution error's message-only construction path). +import {describe, expect, test} from 'bun:test'; +import {DexpaceError} from '../http/errors.js'; +import {AuthResolutionError, PlaintextCredentialError} from './errors.js'; + +describe('AuthResolutionError', () => { + test('a plain message constructs directly', () => { + const error = new AuthResolutionError( + 'token provider returned an expired token', + ); + expect(error.name).toBe('AuthResolutionError'); + expect(error.message).toContain('expired'); + }); + + test('the message-only path carries no scheme lists (AUTH-35)', () => { + const error = new AuthResolutionError('token provider returned null'); + expect(error.requiredSchemes).toBeUndefined(); + expect(error.availableSchemes).toBeUndefined(); + }); + + test('unsatisfiable() names both the required and available schemes', () => { + const error = AuthResolutionError.unsatisfiable( + ['BASIC', 'DIGEST'], + ['API_KEY'], + ); + expect(error.message).toContain('BASIC'); + expect(error.message).toContain('DIGEST'); + expect(error.message).toContain('API_KEY'); + }); + + test('unsatisfiable() also carries them as indexable fields, not only as prose (AUTH-6)', () => { + const error = AuthResolutionError.unsatisfiable( + ['BASIC', 'DIGEST'], + ['API_KEY'], + ); + expect(error.requiredSchemes).toEqual(['BASIC', 'DIGEST']); // preference order preserved + expect(error.availableSchemes).toEqual(['API_KEY']); + }); + + test('unsatisfiable() copies the caller arrays rather than aliasing them', () => { + const required = ['BASIC']; + const error = AuthResolutionError.unsatisfiable(required, []); + required.push('DIGEST'); + expect(error.requiredSchemes).toEqual(['BASIC']); + }); + + test('descends from DexpaceError, so a caller can catch the whole taxonomy', () => { + expect(new AuthResolutionError('x')).toBeInstanceOf(DexpaceError); + }); +}); + +describe('AuthResolutionError copies its scheme lists (AUTH-6)', () => { + test('the constructor copies, so a caller mutating its array cannot reach the error', () => { + const required = ['BASIC']; + const available = ['DIGEST']; + const error = new AuthResolutionError('nope', required, available); + required.push('OAUTH2'); + available.push('API_KEY'); + expect(error.requiredSchemes).toEqual(['BASIC']); + expect(error.availableSchemes).toEqual(['DIGEST']); + }); + + test('unsatisfiable() delegates to that one copy site', () => { + const required = ['BASIC']; + const error = AuthResolutionError.unsatisfiable(required, []); + required.push('DIGEST'); + expect(error.requiredSchemes).toEqual(['BASIC']); + }); +}); + +describe('PlaintextCredentialError', () => { + test('names the step and the resolved scheme', () => { + const error = new PlaintextCredentialError('authStep', 'BASIC'); + expect(error.message).toContain('authStep'); + expect(error.message).toContain('BASIC'); + }); + + test('carries them as fields too (error-handling.md)', () => { + const error = new PlaintextCredentialError('authStep', 'BASIC'); + expect(error.stepName).toBe('authStep'); + expect(error.scheme).toBe('BASIC'); + expect(error.name).toBe('PlaintextCredentialError'); + }); +}); diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts new file mode 100644 index 0000000..174e36d --- /dev/null +++ b/packages/core/src/auth/errors.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/errors.ts +import {DexpaceError} from '../http/errors.js'; + +/** + * AUTH-6 (the selected tier lists no satisfiable scheme) and AUTH-35 (a `TokenProvider` returned + * null or an already-expired token). + * + * AUTH-6, not AUTH-4: AUTH-4 governs only WHICH tier is selected — most-specific-present, with no + * fall-through — and it is AUTH-6 that requires "a distinct auth-resolution error (carrying both the + * required schemes in preference order and the available schemes)" when that tier turns out to be + * unsatisfiable. + * + * The scheme lists are `readonly` FIELDS, not only interpolated prose. AUTH-6 requires the error to + * carry both the required schemes in preference order and the available schemes, and + * `docs/knowledge/error-handling.md` requires identifying inputs to be `readonly` fields "so they + * survive serialization and appear in structured logs". Both are `undefined` on the AUTH-35 + * construction path, which has no scheme lists to carry. + * + * Typed `readonly string[]` rather than `readonly AuthScheme[]`: this module is the taxonomy leaf + * every other auth module depends on, and `scheme.ts` has no reason to depend back on it. A union of + * string literals is assignable to `string`, so callers pass `AuthScheme[]` values unchanged. + * + * @public + */ +export class AuthResolutionError extends DexpaceError { + /** The selected tier's schemes, in declared preference order. Absent on the AUTH-35 path. */ + readonly requiredSchemes: readonly string[] | undefined; + /** The schemes a credential was actually configured for. Absent on the AUTH-35 path. */ + readonly availableSchemes: readonly string[] | undefined; + + /** + * Both lists are COPIED, not aliased. They are typed `readonly` and this class is public surface, + * so a caller-owned array stored by reference would leave a `readonly` field whose contents change + * after the error was constructed. `unsatisfiable()` below delegates here rather than copying a + * second time, so there is exactly one copy site. + * + * @param message - the human-readable failure description. + * @param requiredSchemes - the selected tier's schemes, in preference order. + * @param availableSchemes - the schemes a credential was configured for. + */ + constructor( + message: string, + requiredSchemes?: readonly string[], + availableSchemes?: readonly string[], + ) { + super(message); + this.requiredSchemes = + requiredSchemes === undefined + ? undefined + : Object.freeze([...requiredSchemes]); + this.availableSchemes = + availableSchemes === undefined + ? undefined + : Object.freeze([...availableSchemes]); + } + + /** + * AUTH-6's unsatisfiable-descriptor case: the caller configured a tier, but none of its listed + * schemes has a matching credential. + * + * @param requiredSchemes - the selected tier's schemes, in preference order. + * @param availableSchemes - the schemes a credential was configured for. + * @returns the error, with both lists copied onto its own fields by the constructor. + */ + static unsatisfiable( + requiredSchemes: readonly string[], + availableSchemes: readonly string[], + ): AuthResolutionError { + return new AuthResolutionError( + `no requirement is satisfiable; required one of [${requiredSchemes.join(', ')}], available: [${availableSchemes.join(', ')}]`, + requiredSchemes, + availableSchemes, + ); + } +} + +/** + * AUTH-28: a credential would have been attached to a non-HTTPS URL. + * + * The offending URL is deliberately NOT carried — a URL can hold userinfo and query-string secrets, + * and `docs/knowledge/error-handling.md` bars interpolating secrets into a message that travels into + * logs. The step name and scheme identify the fault without that risk. + * + * @public + */ +export class PlaintextCredentialError extends DexpaceError { + /** The concrete step that refused, as AUTH-28 requires the error to name. */ + readonly stepName: string; + /** The resolved auth scheme whose credential would have been stamped. */ + readonly scheme: string; + + /** + * @param stepName - the concrete step that refused. + * @param scheme - the resolved auth scheme. + */ + constructor(stepName: string, scheme: string) { + super( + `${stepName} refuses to send a ${scheme} credential over a non-HTTPS URL`, + ); + this.stepName = stepName; + this.scheme = scheme; + } +} diff --git a/packages/core/src/auth/md5.test.ts b/packages/core/src/auth/md5.test.ts new file mode 100644 index 0000000..8348a2e --- /dev/null +++ b/packages/core/src/auth/md5.test.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/md5.test.ts +// Exercises: AUTH-15, AUTH-17 (MD5 correctness against RFC 1321's own test vectors, and the +// lower-case hex rendering the Digest response is built from). +import {describe, expect, test} from 'bun:test'; +import {md5, toHex} from './md5.js'; + +function md5Hex(input: string): string { + return toHex(md5(new TextEncoder().encode(input))); +} + +describe('md5 (RFC 1321 test vectors)', () => { + test('the empty string', () => { + expect(md5Hex('')).toBe('d41d8cd98f00b204e9800998ecf8427e'); + }); + + test('"a"', () => { + expect(md5Hex('a')).toBe('0cc175b9c0f1b6a831c399e269772661'); + }); + + test('"abc"', () => { + expect(md5Hex('abc')).toBe('900150983cd24fb0d6963f7d28e17f72'); + }); + + test('"message digest"', () => { + expect(md5Hex('message digest')).toBe('f96b697d7cb7938d525a2f31aaf161d0'); + }); + + test('the lowercase alphabet, exercising a multi-block input', () => { + expect(md5Hex('abcdefghijklmnopqrstuvwxyz')).toBe( + 'c3fcd3d76192e4007dfb496cca67e13b', + ); + }); + + test('the 62-character alphanumeric vector', () => { + expect( + md5Hex('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'), + ).toBe('d174ab98d277d9f5a5611c2c9f419d9f'); + }); + + test('the 80-digit vector, exercising a two-block input', () => { + expect(md5Hex('1234567890'.repeat(8))).toBe( + '57edf4a22be3c955ac49da2e2107b67a', + ); + }); + + test('a 55-byte input, the last length that pads into a single block', () => { + expect(md5Hex('a'.repeat(55))).toBe('ef1772b6dff9a122358552954ad0df65'); + }); + + test('a 56-byte input, the first length that forces a second block', () => { + expect(md5Hex('a'.repeat(56))).toBe('3b0c8ac703f828b04c6c197006d17218'); + }); + + test('a 64-byte input, exactly one block before padding', () => { + expect(md5Hex('a'.repeat(64))).toBe('014842d480b571495a4a0363793f7367'); + }); + + test('non-ASCII bytes hash by their UTF-8 encoding', () => { + expect(md5Hex('é')).toBe('66ddcd97cfdeabb2f6fb8a999b4bc76f'); + }); + + test('the digest is 16 bytes', () => { + expect(md5(new Uint8Array()).length).toBe(16); + }); + + test('is pure -- the same input hashes identically twice', () => { + const input = new TextEncoder().encode('repeat me'); + expect(toHex(md5(input))).toBe(toHex(md5(input))); + }); +}); + +describe('toHex', () => { + test('pads each byte to two lower-case hex digits', () => { + expect(toHex(new Uint8Array([0, 15, 255]))).toBe('000fff'); + }); + + test('renders the empty array as the empty string', () => { + expect(toHex(new Uint8Array())).toBe(''); + }); +}); diff --git a/packages/core/src/auth/md5.ts b/packages/core/src/auth/md5.ts new file mode 100644 index 0000000..445361b --- /dev/null +++ b/packages/core/src/auth/md5.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/md5.ts + +/** + * RFC 1321 MD5, hand-rolled and dependency-free. + * + * Web Crypto's `subtle.digest()` deliberately excludes MD5 — the algorithm is out of the standard on + * security grounds — yet RFC 7616 Digest still requires MD5/MD5-sess for interop with servers that + * have not adopted SHA-256 (AUTH-15). Adding an npm dependency for it would violate SEAM-1's + * zero-runtime-dependency rule, and reaching for `node:crypto` would cost the portability to + * browsers/Deno/Workers that `sdk-design-nodejs/06` picks Web Crypto to keep. + * + * @packageDocumentation + */ + +const SHIFTS = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, + 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, + 21, +] as const; + +// `/*#__PURE__*/`, because this is a top-level CALL, and `docs/knowledge/performance.md` is explicit +// that modules must do no work at import time — a top-level call is a side effect the bundler must +// preserve, and that pins the module in the bundle. `@dexpace/core` declares `"sideEffects": false`; +// without the annotation a bundler cannot prove these 64 `Math.sin` calls are pure, so `md5.ts` and +// its table are retained by every consumer that transitively imports anything reaching them, +// including one that never touches Digest. +// Deeply immutable via the freeze, which is what earns the CONSTANT_CASE (naming-conventions.md). +const CONSTANTS: readonly number[] = /*#__PURE__*/ Object.freeze( + Array.from( + {length: 64}, + (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0, + ), +); + +function leftRotate(value: number, bits: number): number { + return ((value << bits) | (value >>> (32 - bits))) >>> 0; +} + +/** RFC 1321 §3.1: pad to a multiple of 64 bytes with a single 0x80, zeros, then the original bit length. */ +function pad(message: Uint8Array): Uint8Array { + const bitLength = BigInt(message.length) * 8n; + const paddingLength = (((56 - ((message.length + 1) % 64)) % 64) + 64) % 64; + const result = new Uint8Array(message.length + 1 + paddingLength + 8); + result.set(message); + result[message.length] = 0x80; + // `result` is freshly allocated, so its byteOffset is 0 and the buffer view needs no offset. + new DataView(result.buffer).setBigUint64(result.length - 8, bitLength, true); + return result; +} + +/** The three state words RFC 1321's per-round auxiliary function reads. Bundled so `roundFunction` + * stays within `max-params`. */ +interface RoundWords { + readonly b: number; + readonly c: number; + readonly d: number; +} + +function roundFunction(i: number, words: RoundWords): number { + const {b, c, d} = words; + if (i < 16) return (b & c) | (~b & d); + if (i < 32) return (d & b) | (~d & c); + if (i < 48) return b ^ c ^ d; + return c ^ (b | ~d); +} + +function messageIndex(i: number): number { + if (i < 16) return i; + if (i < 32) return (5 * i + 1) % 16; + if (i < 48) return (3 * i + 5) % 16; + return (7 * i) % 16; +} + +interface State { + a: number; + b: number; + c: number; + d: number; +} + +function processBlock(words: readonly number[], state: State): State { + let {a, b, c, d} = state; + for (let i = 0; i < 64; i += 1) { + const f = + (roundFunction(i, {b, c, d}) + + a + + (CONSTANTS[i] ?? 0) + + (words[messageIndex(i)] ?? 0)) >>> + 0; + a = d; + d = c; + c = b; + b = (b + leftRotate(f, SHIFTS[i] ?? 0)) >>> 0; + } + return { + a: (state.a + a) >>> 0, + b: (state.b + b) >>> 0, + c: (state.c + c) >>> 0, + d: (state.d + d) >>> 0, + }; +} + +/** + * Computes the RFC 1321 MD5 digest of `message` (AUTH-15–AUTH-17). + * + * Pure: no shared state, no allocation the caller can observe, safe for concurrent invocation + * (AUTH-24). + * + * @param message - the bytes to hash. + * @returns the 16-byte digest. + * + * @internal + */ +export function md5(message: Uint8Array): Uint8Array { + const data = pad(message); + // `data` comes straight from `pad`, so it is a fresh, offset-0 view over its own buffer. + const view = new DataView(data.buffer); + let state: State = { + a: 0x67452301, + b: 0xefcdab89, + c: 0x98badcfe, + d: 0x10325476, + }; + + for (let chunkStart = 0; chunkStart < data.length; chunkStart += 64) { + const words = Array.from({length: 16}, (_, i) => + view.getUint32(chunkStart + i * 4, true), + ); + state = processBlock(words, state); + } + + const digest = new Uint8Array(16); + const outView = new DataView(digest.buffer); + outView.setUint32(0, state.a, true); + outView.setUint32(4, state.b, true); + outView.setUint32(8, state.c, true); + outView.setUint32(12, state.d, true); + return digest; +} + +/** + * Renders bytes as lower-case hex, two digits per byte — the form AUTH-17 requires for HA1/HA2 and + * the Digest response. + * + * @param bytes - the bytes to render. + * @returns the lower-case hex string, twice as long as `bytes`. + * + * @internal + */ +export function toHex(bytes: Uint8Array): string { + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/core/src/auth/preset.test.ts b/packages/core/src/auth/preset.test.ts new file mode 100644 index 0000000..dcc64fa --- /dev/null +++ b/packages/core/src/auth/preset.test.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/preset.test.ts +// Exercises: PIPE-24 ("installs into empty pillar slots only" -- true by construction, since the preset +// always starts from a fresh PipelineBuilder), PIPE-39 (installs exactly the pillars that exist), and +// jointly with 5b: PIPE-2's "auth executes per redirect hop, not once for the whole call" plus +// AUTH-29's marker-CONSUMPTION side (5b produced the marker and routed consumption here). +import {describe, expect, test} from 'bun:test'; +import {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import {PipelineBuilder} from '../pipeline/builder.js'; +import type {StepDescriptor} from '../pipeline/step.js'; +import {CROSS_ORIGIN_MARKER_HEADER} from '../redirect/cross-origin.js'; +import {REDIRECT_STEP_TYPE} from '../redirect/redirect-step.js'; +import {withRedirect} from '../redirect/strip-marker-step.js'; +import {RETRY_STEP_TYPE} from '../retry/retry-step.js'; +import {FakeTransport, countingResponse} from '../testing/fake-transport.js'; +import {AUTH_STEP_TYPE} from './auth-step.js'; +import {createBearerToken} from './credential.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {standardResilience, type StandardResilienceOptions} from './preset.js'; +import {createAuthRequirement} from './requirement.js'; + +function aRequest(url = 'https://example.com/start'): Request { + return Request.newBuilder().url(url).build(); +} + +// `FakeTransport` does not itself set a Location -- 5b's `decide()` reads it off `Response.headers`, so a +// scripted 3xx entry must carry one explicitly. `setInbound`, not `set`: Location is an inbound header. +function withLocation(response: Response, location: string): Response { + return response + .newBuilder() + .headers( + response.headers.newBuilder().setInbound('Location', location).build(), + ) + .build(); +} + +function bearerOptions(): StandardResilienceOptions { + return { + auth: { + credentials: { + bearer: { + provider: () => Promise.resolve(createBearerToken('tok', 60_000)), + }, + }, + tiers: {client: createAuthDescriptor([createAuthRequirement('OAUTH2')])}, + clock: {now: () => 0}, + }, + }; +} + +describe('standardResilience', () => { + test('installs exactly the three pillars that exist, plus 5b’s marker guard (PIPE-24/PIPE-39)', () => { + const runtime = standardResilience( + new FakeTransport([countingResponse(200).response]), + bearerOptions(), + ); + const types = runtime.steps.map(step => step.type); + + expect(types).toContain(REDIRECT_STEP_TYPE); + expect(types).toContain(RETRY_STEP_TYPE); + expect(types).toContain(AUTH_STEP_TYPE); + // redirectStep + its POST_AUTH marker guard + retryStep + authStep. LOGGING and SERDE stay empty: + // LOGGING's real step ships in Phase 7b, which amends this preset in its own plan. + expect(types).toHaveLength(4); + }); + + test('the pillars flatten in redirect-then-retry-then-auth order (AUTH-27/PIPE-2)', () => { + const runtime = standardResilience( + new FakeTransport([countingResponse(200).response]), + bearerOptions(), + ); + const order = runtime.steps.map(step => step.stage); + + expect(order.indexOf('REDIRECT')).toBeLessThan(order.indexOf('RETRY')); + expect(order.indexOf('RETRY')).toBeLessThan(order.indexOf('AUTH')); + }); + + test('NO_AUTH is the default when no auth option is supplied', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + const runtime = standardResilience(transport); + + await runtime.send(aRequest('https://example.com')); + + expect( + transport.calls[0]?.request.headers.get('Authorization'), + ).toBeUndefined(); + }); + + test('the default NO_AUTH step does not trip the HTTPS guard on a plain-HTTP call (AUTH-28)', async () => { + const transport = new FakeTransport([countingResponse(200).response]); + + await standardResilience(transport).send(aRequest('http://example.com')); + + expect(transport.sendCount).toBe(1); + }); +}); + +describe('standardResilience with redirects (PIPE-2 + AUTH-29, jointly with 5b)', () => { + test('joint conformance (PIPE-2 + AUTH-29): credential absent on the cross-origin hop, restamped on return to same-origin', async () => { + const toCrossOrigin = withLocation( + countingResponse(302).response, + 'https://evil.example/mid', + ); + const backToSeedOrigin = withLocation( + countingResponse(302).response, + 'https://example.com/final', + ); + const finalHop = countingResponse(200); + const transport = new FakeTransport([ + toCrossOrigin, + backToSeedOrigin, + finalHop.response, + ]); + + const runtime = standardResilience(transport, bearerOptions()); + const response = await runtime.send(aRequest()); + + expect(transport.calls).toHaveLength(3); + // Seed hop: same-origin, stamped. + expect(transport.calls[0]?.request.headers.get('Authorization')).toBe( + 'Bearer tok', + ); + // Cross-origin hop: suppressed (AUTH-29). + expect( + transport.calls[1]?.request.headers.get('Authorization'), + ).toBeUndefined(); + // Back to the seed origin: re-stamped, proving auth re-runs PER HOP (PIPE-2). + expect(transport.calls[2]?.request.headers.get('Authorization')).toBe( + 'Bearer tok', + ); + expect(response).toBe(finalHop.response); + }); +}); + +describe('the cross-origin marker is load-bearing on both sides (REDIR-11/AUTH-29)', () => { + test('the internal cross-origin marker never reaches the wire (REDIR-11/AUTH-29)', async () => { + const toCrossOrigin = withLocation( + countingResponse(302).response, + 'https://evil.example/mid', + ); + const transport = new FakeTransport([ + toCrossOrigin, + countingResponse(200).response, + ]); + + await standardResilience(transport, bearerOptions()).send(aRequest()); + + for (const call of transport.calls) { + expect(call.request.headers.has(CROSS_ORIGIN_MARKER_HEADER)).toBe(false); + } + }); + + test('neither the redirect guard nor the marker check alone is sufficient -- both are independently necessary', async () => { + // A minimal AUTH-stage step that IGNORES the cross-origin marker and always stamps -- standing in + // for "what would happen if 5c's marker check were removed". With THIS step installed instead of the + // real authStep(), the credential leaks onto the cross-origin hop, proving the marker suppresses + // something observable rather than headers merely happening to come out empty. + const leakyAuthStep: StepDescriptor = { + type: Symbol('leaky-auth'), + stage: 'AUTH', + fn: (request, ctx) => { + const stamped = request + .newBuilder() + .headers( + request.headers + .newBuilder() + .set('Authorization', 'Bearer leaked') + .build(), + ) + .build(); + return ctx.next(stamped); + }, + }; + const toCrossOrigin = withLocation( + countingResponse(302).response, + 'https://evil.example/mid', + ); + const transport = new FakeTransport([ + toCrossOrigin, + countingResponse(200).response, + ]); + + const runtime = withRedirect(new PipelineBuilder(transport)) + .append(leakyAuthStep) + .build(); + await runtime.send(aRequest()); + + // 5b's redirect step already strips Authorization unconditionally on every re-issue (REDIR-7), so + // this variant demonstrates the OTHER half: a leaky auth step re-attaches a credential redirect just + // stripped, proving 5b's stripping alone is not sufficient either. Both layers are load-bearing. + expect(transport.calls[1]?.request.headers.get('Authorization')).toBe( + 'Bearer leaked', + ); + }); +}); diff --git a/packages/core/src/auth/preset.ts b/packages/core/src/auth/preset.ts new file mode 100644 index 0000000..a844b71 --- /dev/null +++ b/packages/core/src/auth/preset.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/preset.ts +import {PipelineBuilder} from '../pipeline/builder.js'; +import type {Runtime} from '../pipeline/runtime.js'; +import type {RedirectSettings} from '../redirect/settings.js'; +import {withRedirect} from '../redirect/strip-marker-step.js'; +import {retryStep, type RetryStepOptions} from '../retry/retry-step.js'; +import type {Transport} from '../seams/transport.js'; +import {authStep, type AuthStepSettings} from './auth-step.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {createAuthRequirement} from './requirement.js'; + +/** + * Per-pillar overrides for {@link standardResilience}. Every slot is optional; an omitted one takes + * that pillar's own defaults. + * + * @public + */ +export interface StandardResilienceOptions { + /** Retry settings and injected seams; omitted yields 5a's spec defaults. */ + readonly retry?: RetryStepOptions | undefined; + /** Redirect policy overrides; omitted yields 5b's spec defaults. */ + readonly redirect?: Partial | undefined; + /** + * Auth configuration. Required if any credential tier is meant to apply; omitted installs a + * `NO_AUTH`-only step, which stamps nothing and never trips the HTTPS guard. + */ + readonly auth?: AuthStepSettings | undefined; +} + +// Built lazily rather than as a top-level `const NO_AUTH_SETTINGS = ...`: a module-scope factory call +// is import-time work a bundler must preserve (`docs/knowledge/performance.md`), and it would pin +// descriptor.ts/requirement.ts into every bundle that imports the preset. The allocation is per call, +// but the preset is constructed once per client, not per request. +function noAuthSettings(): AuthStepSettings { + return { + credentials: {}, + tiers: {client: createAuthDescriptor([createAuthRequirement('NO_AUTH')])}, + }; +} + +/** + * Assembles the standard resilience pipeline: redirect, then retry, then auth (PIPE-24, PIPE-39). + * + * The order is AUTH-27's "redirect wraps retry wraps auth", so the auth step re-resolves and + * re-stamps per redirect hop and per retry attempt (PIPE-2). Redirect is installed through 5b's + * `withRedirect()`, which seats the pillar step AND its `POST_AUTH` cross-origin-marker guard + * together, so the internal marker can never reach the wire. + * + * PIPE-24's "installs into empty pillar slots only" is true BY CONSTRUCTION: this function always + * starts from a fresh `PipelineBuilder`, so no slot can be occupied and no runtime check is needed. A + * caller wanting to layer this preset onto an already-customized builder reaches for + * {@link PipelineBuilder.seedFrom} (`'nest'` or `'flatten'`) rather than this function growing a + * "skip occupied slots" branch — the two features compose. + * + * `LOGGING` and `SERDE` stay empty. `LOGGING`'s real step ships in Phase 7b, which executes after this + * phase and amends this function with a fourth `append` in its own plan; `SERDE` remains reserved with + * no shipped behavior anywhere in this roadmap's current scope. That is a scope boundary, not a + * deviation — the reference's preset description includes instrumentation, and this preset grows to + * match once a real logging step exists. + * + * This function only assembles the pipeline. The failures below surface from the returned runtime's + * `send()`, and are documented here because this factory is where a caller chooses the auth + * configuration that determines whether they can occur at all. + * + * @param transport - the terminal transport. Never closed by the pipeline (PIPE-27). + * @param options - per-pillar overrides. + * @returns the built, immutable runtime. + * @throws PlaintextCredentialError — from the returned runtime's `send()` — when a credentialed scheme + * meets a non-HTTPS URL (AUTH-28). + * @throws AuthResolutionError — from the returned runtime's `send()` — when no configured credential + * satisfies the resolved auth tier (AUTH-6; AUTH-4 governs only WHICH tier is selected), or a token + * provider returns a null or already-expired token (AUTH-35). + * @throws HeaderValidationError — from the returned runtime's `send()` — when credential material + * will not fit in a header value (HTTP-18). + * @throws InvariantViolation — synchronously from this function — when any pillar's settings are + * invalid, including a non-finite bearer refresh margin or a non-header-safe Digest username. A + * caller-supplied `TokenProvider` or `challengeHook` error passes through `send()` unwrapped. + * + * @example + * ```ts + * const client = standardResilience(transport, { + * auth: { + * credentials: {apiKey: {credential: new ApiKeyCredential(process.env.API_KEY ?? '')}}, + * tiers: {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + * }, + * }); + * const response = await client.send( + * Request.newBuilder().url('https://api.example.com/v1/things').build(), + * ); + * ``` + * + * @public + */ +export function standardResilience( + transport: Transport, + options: StandardResilienceOptions = {}, +): Runtime { + const builder = new PipelineBuilder(transport); + return withRedirect(builder, options.redirect) + .append(retryStep(options.retry)) + .append(authStep(options.auth ?? noAuthSettings())) + .build(); +} diff --git a/packages/core/src/auth/requirement.test.ts b/packages/core/src/auth/requirement.test.ts new file mode 100644 index 0000000..ddb2c26 --- /dev/null +++ b/packages/core/src/auth/requirement.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/requirement.test.ts +// Exercises: AUTH-2 (frozen data shape, defensive copies of scopes/params, value equality). +import {describe, expect, test} from 'bun:test'; +import {authRequirementsEqual, createAuthRequirement} from './requirement.js'; + +describe('createAuthRequirement', () => { + test('defaults scopes to empty and params to an empty map', () => { + const requirement = createAuthRequirement('BASIC'); + expect(requirement.scopes).toEqual([]); + expect(requirement.params.size).toBe(0); + }); + + test('is frozen', () => { + expect(Object.isFrozen(createAuthRequirement('BASIC'))).toBe(true); + }); + + test('freezes the scopes array too', () => { + expect( + Object.isFrozen(createAuthRequirement('OAUTH2', ['read']).scopes), + ).toBe(true); + }); + + test('defensively copies the scopes array', () => { + const scopes = ['read']; + const requirement = createAuthRequirement('OAUTH2', scopes); + scopes.push('write'); + expect(requirement.scopes).toEqual(['read']); + }); + + test('defensively copies the params map', () => { + const params = new Map([['tenant', 'a']]); + const requirement = createAuthRequirement('OAUTH2', [], params); + params.set('tenant', 'b'); + expect(requirement.params.get('tenant')).toBe('a'); + }); +}); + +describe('authRequirementsEqual', () => { + test('true for identical scheme/scopes/params, regardless of construction order', () => { + const a = createAuthRequirement( + 'OAUTH2', + ['read', 'write'], + new Map([['tenant', 'x']]), + ); + const b = createAuthRequirement( + 'OAUTH2', + ['read', 'write'], + new Map([['tenant', 'x']]), + ); + expect(authRequirementsEqual(a, b)).toBe(true); + }); + + test('false for a differing scheme', () => { + expect( + authRequirementsEqual( + createAuthRequirement('BASIC'), + createAuthRequirement('DIGEST'), + ), + ).toBe(false); + }); + + test('false for differing scopes', () => { + const a = createAuthRequirement('OAUTH2', ['read']); + const b = createAuthRequirement('OAUTH2', ['write']); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('scope ORDER is part of the value, not a set comparison', () => { + const a = createAuthRequirement('OAUTH2', ['read', 'write']); + const b = createAuthRequirement('OAUTH2', ['write', 'read']); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('false for a differing scope count', () => { + const a = createAuthRequirement('OAUTH2', ['read']); + const b = createAuthRequirement('OAUTH2', ['read', 'write']); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('false for differing params', () => { + const a = createAuthRequirement('OAUTH2', [], new Map([['tenant', 'x']])); + const b = createAuthRequirement('OAUTH2', [], new Map([['tenant', 'y']])); + expect(authRequirementsEqual(a, b)).toBe(false); + }); + + test('false for a differing param count', () => { + const a = createAuthRequirement('OAUTH2', [], new Map([['tenant', 'x']])); + const b = createAuthRequirement( + 'OAUTH2', + [], + new Map([ + ['tenant', 'x'], + ['region', 'eu'], + ]), + ); + expect(authRequirementsEqual(a, b)).toBe(false); + }); +}); diff --git a/packages/core/src/auth/requirement.ts b/packages/core/src/auth/requirement.ts new file mode 100644 index 0000000..f69f2c1 --- /dev/null +++ b/packages/core/src/auth/requirement.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/requirement.ts +import type {AuthScheme} from './scheme.js'; + +/** + * AUTH-2: one scheme bound to its own OAuth scopes and params. + * + * A frozen data shape plus a pure equality function — the same "data and functions, not objects" + * call 4a made for context types and 4c made for `Stage`, rather than a class with an `equals()` + * method. + * + * @public + */ +export interface AuthRequirement { + /** The bound scheme. */ + readonly scheme: AuthScheme; + /** Meaningful only for `OAUTH2`; preserved verbatim, never inspected by resolution (AUTH-2). */ + readonly scopes: readonly string[]; + /** Scheme-specific parameters, preserved verbatim and never inspected by resolution (AUTH-2). */ + readonly params: ReadonlyMap; +} + +/** + * Builds a frozen {@link AuthRequirement}, defensively copying both collections so a caller mutating + * its inputs afterwards cannot reach the stored value (AUTH-2). + * + * @param scheme - the scheme this requirement binds. + * @param scopes - OAuth scopes; meaningful only for `OAUTH2`. + * @param params - scheme-specific parameters. + * @returns the frozen requirement. + * + * @public + */ +export function createAuthRequirement( + scheme: AuthScheme, + scopes: readonly string[] = [], + params: ReadonlyMap = new Map(), +): AuthRequirement { + // `Object.freeze` is SHALLOW. `docs/knowledge/data-modeling.md` requires a frozen value object to + // hold only primitives or already-frozen/read-only values, never a mutable object that stays + // writable behind the freeze. `new Map(params)` satisfies AUTH-2's literal clause -- caller-side + // mutation cannot reach the stored value -- but leaves the copy itself writable behind the + // `ReadonlyMap` type. Rebuilding a frozen Map on every read is not worth it for a value this small + // and this rarely read; instead the copy is made once here, and nothing in this package ever + // re-casts `AuthRequirement['params']` back to `Map` (AUTH-2 also bars resolution from inspecting + // params at all), which is what keeps the `ReadonlyMap` type honest in practice. + return Object.freeze({ + scheme, + scopes: Object.freeze([...scopes]), + params: new Map(params), + }); +} + +function scopesEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((scope, index) => scope === b[index]); +} + +function paramsEqual( + a: ReadonlyMap, + b: ReadonlyMap, +): boolean { + return ( + a.size === b.size && [...a].every(([key, value]) => b.get(key) === value) + ); +} + +/** + * AUTH-2's value-based equality: over scheme, scopes (ordered), and params. + * + * @param a - the left requirement. + * @param b - the right requirement. + * @returns `true` when all three components match. + * + * @public + */ +export function authRequirementsEqual( + a: AuthRequirement, + b: AuthRequirement, +): boolean { + return ( + a.scheme === b.scheme && + scopesEqual(a.scopes, b.scopes) && + paramsEqual(a.params, b.params) + ); +} diff --git a/packages/core/src/auth/resolve.test.ts b/packages/core/src/auth/resolve.test.ts new file mode 100644 index 0000000..8b87d37 --- /dev/null +++ b/packages/core/src/auth/resolve.test.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/resolve.test.ts +// Exercises: AUTH-4 (perCall ?? operation ?? client, first PRESENT wins, no fallthrough on failure), +// AUTH-5 (first requirement whose scheme is NO_AUTH or in availableSchemes wins), AUTH-6 (all tiers +// absent is a programmer error), AUTH-7 (pure function, no hidden state). +import {describe, expect, test} from 'bun:test'; +import {InvariantViolation} from '../invariant.js'; +import {createAuthDescriptor} from './descriptor.js'; +import {AuthResolutionError} from './errors.js'; +import {createAuthRequirement} from './requirement.js'; +import {resolveAuthRequirement} from './resolve.js'; + +describe('tier selection (AUTH-4)', () => { + test('perCall wins when present, even if operation/client are also present', () => { + const requirement = resolveAuthRequirement( + { + perCall: createAuthDescriptor([createAuthRequirement('BASIC')]), + operation: createAuthDescriptor([createAuthRequirement('DIGEST')]), + client: createAuthDescriptor([createAuthRequirement('API_KEY')]), + }, + new Set(['BASIC', 'DIGEST', 'API_KEY']), + ); + expect(requirement.scheme).toBe('BASIC'); + }); + + test('operation wins over client when perCall is absent', () => { + const requirement = resolveAuthRequirement( + { + operation: createAuthDescriptor([createAuthRequirement('DIGEST')]), + client: createAuthDescriptor([createAuthRequirement('API_KEY')]), + }, + new Set(['DIGEST', 'API_KEY']), + ); + expect(requirement.scheme).toBe('DIGEST'); + }); + + test('client is used when it is the only tier present', () => { + const requirement = resolveAuthRequirement( + {client: createAuthDescriptor([createAuthRequirement('API_KEY')])}, + new Set(['API_KEY']), + ); + expect(requirement.scheme).toBe('API_KEY'); + }); + + test('a lower tier is NEVER consulted once a higher one is present, even if unsatisfiable', () => { + expect(() => + resolveAuthRequirement( + { + perCall: createAuthDescriptor([createAuthRequirement('DIGEST')]), + client: createAuthDescriptor([createAuthRequirement('BASIC')]), + }, + // would satisfy client's tier, but perCall is present and DIGEST is not available + new Set(['BASIC']), + ), + ).toThrow(AuthResolutionError); + }); + + test('an explicitly-undefined higher tier is treated as absent', () => { + const requirement = resolveAuthRequirement( + { + perCall: undefined, + client: createAuthDescriptor([createAuthRequirement('BASIC')]), + }, + new Set(['BASIC']), + ); + expect(requirement.scheme).toBe('BASIC'); + }); +}); + +describe('within-descriptor selection (AUTH-5)', () => { + test('the first requirement whose scheme is available wins, in preference order', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('OAUTH2'), + createAuthRequirement('BASIC'), + ]); + const requirement = resolveAuthRequirement( + {client: descriptor}, + new Set(['BASIC']), + ); + expect(requirement.scheme).toBe('BASIC'); + }); + + test('NO_AUTH always wins regardless of availableSchemes', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('NO_AUTH'), + createAuthRequirement('BASIC'), + ]); + const requirement = resolveAuthRequirement({client: descriptor}, new Set()); + expect(requirement.scheme).toBe('NO_AUTH'); + }); + + test('scopes and params are never inspected -- only the scheme decides', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('OAUTH2', ['read'], new Map([['tenant', 'x']])), + ]); + const requirement = resolveAuthRequirement( + {client: descriptor}, + new Set(['OAUTH2']), + ); + expect(requirement.scopes).toEqual(['read']); + expect(requirement.params.get('tenant')).toBe('x'); + }); + + test('an unsatisfiable descriptor throws AuthResolutionError naming both schemes', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('DIGEST')]); + try { + resolveAuthRequirement({client: descriptor}, new Set(['BASIC'])); + throw new Error('expected a throw'); + } catch (error) { + expect(error).toBeInstanceOf(AuthResolutionError); + expect((error as Error).message).toContain('DIGEST'); + expect((error as Error).message).toContain('BASIC'); + } + }); + + test('the thrown error carries required schemes in PREFERENCE order (AUTH-6)', () => { + const descriptor = createAuthDescriptor([ + createAuthRequirement('DIGEST'), + createAuthRequirement('OAUTH2'), + ]); + try { + resolveAuthRequirement({client: descriptor}, new Set(['BASIC'])); + throw new Error('expected a throw'); + } catch (error) { + expect((error as AuthResolutionError).requiredSchemes).toEqual([ + 'DIGEST', + 'OAUTH2', + ]); + expect((error as AuthResolutionError).availableSchemes).toEqual([ + 'BASIC', + ]); + } + }); +}); + +describe('AUTH-6: all tiers absent', () => { + test('is a programmer error, not AuthResolutionError', () => { + expect(() => resolveAuthRequirement({}, new Set())).toThrow( + InvariantViolation, + ); + expect(() => resolveAuthRequirement({}, new Set())).not.toThrow( + AuthResolutionError, + ); + }); +}); + +describe('AUTH-7: purity', () => { + test('the same inputs always resolve to an equal requirement', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('BASIC')]); + const first = resolveAuthRequirement( + {client: descriptor}, + new Set(['BASIC']), + ); + const second = resolveAuthRequirement( + {client: descriptor}, + new Set(['BASIC']), + ); + // Same object identity: resolve() picks from the existing descriptor, building nothing new. + expect(first).toBe(second); + }); +}); diff --git a/packages/core/src/auth/resolve.ts b/packages/core/src/auth/resolve.ts new file mode 100644 index 0000000..4b7fdca --- /dev/null +++ b/packages/core/src/auth/resolve.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/resolve.ts +import {invariant} from '../invariant.js'; +import type {AuthDescriptor} from './descriptor.js'; +import {AuthResolutionError} from './errors.js'; +import type {AuthRequirement} from './requirement.js'; +import type {AuthScheme} from './scheme.js'; + +/** + * AUTH-4's three configuration tiers, most specific first. Every slot is optional; at least one must + * be present at resolution time. + * + * @public + */ +export interface AuthTiers { + /** The per-call override, sourced from `RequestOptions.auth` by the AUTH pillar step. */ + readonly perCall?: AuthDescriptor | undefined; + /** The per-operation tier. No shipped source yet — see the roadmap's Deferred Items Log. */ + readonly operation?: AuthDescriptor | undefined; + /** The client-wide tier, fixed at step construction. */ + readonly client?: AuthDescriptor | undefined; +} + +/** + * Resolves the single {@link AuthRequirement} a call should satisfy (AUTH-4, AUTH-5, AUTH-7). + * + * Tier selection is `perCall ?? operation ?? client` — the first tier PRESENT, not the first that + * succeeds. If the selected tier lists no satisfiable scheme, {@link AuthResolutionError} is thrown + * naming that tier's schemes; a lower tier is never consulted, because the caller asked for the + * override explicitly (AUTH-4). + * + * Satisfiability is judged on scheme identity alone (AUTH-5): `NO_AUTH` always, otherwise membership + * in `availableSchemes`. No concrete credential value is ever inspected, which is why the caller + * derives `availableSchemes` from the credential types it configured rather than passing credentials + * in. + * + * Pure and stateless (AUTH-7): the returned requirement is the very object the descriptor already + * holds, not a copy. + * + * @param tiers - the three configuration tiers; at least one must be present. + * @param availableSchemes - the schemes a credential is actually configured for. + * @returns the first satisfiable requirement from the selected tier, in declared order. + * @throws AuthResolutionError when the selected tier lists no satisfiable scheme (AUTH-6). + * @throws InvariantViolation when every tier is absent — a caller misconfiguration, not an + * operational failure (AUTH-6, per the plan's Global Constraints). + * + * @public + */ +export function resolveAuthRequirement( + tiers: AuthTiers, + availableSchemes: ReadonlySet, +): AuthRequirement { + const descriptor = tiers.perCall ?? tiers.operation ?? tiers.client; + invariant( + descriptor !== undefined, + 'resolveAuthRequirement: at least one auth tier must be configured', + ); + + const match = descriptor.requirements.find( + requirement => + requirement.scheme === 'NO_AUTH' || + availableSchemes.has(requirement.scheme), + ); + if (match === undefined) { + const requiredSchemes = descriptor.requirements.map( + requirement => requirement.scheme, + ); + throw AuthResolutionError.unsatisfiable(requiredSchemes, [ + ...availableSchemes, + ]); + } + return match; +} diff --git a/packages/core/src/auth/scheme.ts b/packages/core/src/auth/scheme.ts new file mode 100644 index 0000000..618cb10 --- /dev/null +++ b/packages/core/src/auth/scheme.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/scheme.ts + +/** + * AUTH-1: the recognized auth scheme set. `NO_AUTH` is a distinct sentinel meaning "may run + * anonymously / skip credential stamping", not a wire scheme. + * + * A string-literal union, not a TypeScript `enum` — `erasableSyntaxOnly` bars enums, and the scheme + * set has no behavior beyond identity and ordering. Same call 4c made for `Stage`. + * + * @public + */ +export type AuthScheme = 'OAUTH2' | 'API_KEY' | 'BASIC' | 'DIGEST' | 'NO_AUTH'; + +// There is deliberately no `AUTH_SCHEMES` array beside the union. One shipped briefly, documented +// "for enumeration", and nothing ever enumerated it: `availableSchemesOf` derives AUTH-5's set from +// which credentials are configured, and every other reader branches on the union exhaustively. Its +// only test asserted the array's five members against the union's five members, which is the +// constant restated rather than a behaviour, and would have passed against any five-element array. diff --git a/packages/core/src/auth/static-key.test.ts b/packages/core/src/auth/static-key.test.ts new file mode 100644 index 0000000..bb470d7 --- /dev/null +++ b/packages/core/src/auth/static-key.test.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/static-key.test.ts +// Exercises: AUTH-26 (uniform over ApiKeyCredential/NameKeyCredential; default header Authorization; +// prefix + exactly one space when set; stateless -- no challenge involved). +import {describe, expect, test} from 'bun:test'; +import {ApiKeyCredential, NameKeyCredential} from './credential.js'; +import {stampStaticKey} from './static-key.js'; + +describe('stampStaticKey', () => { + test('defaults to the Authorization header, no prefix', () => { + const stamp = stampStaticKey(new ApiKeyCredential('secret')); + expect(stamp.headerName).toBe('Authorization'); + expect(stamp.headerValue).toBe('secret'); + }); + + test('applies a configured prefix with exactly one separating space', () => { + const stamp = stampStaticKey(new ApiKeyCredential('secret'), { + prefix: 'Bearer', + }); + expect(stamp.headerValue).toBe('Bearer secret'); + }); + + test('an empty prefix still contributes its separating space, rather than being ignored', () => { + // `undefined` means "no prefix"; `''` is a caller who explicitly configured one. Collapsing the + // two would make the option's absent state unreachable. + expect( + stampStaticKey(new ApiKeyCredential('secret'), {prefix: ''}).headerValue, + ).toBe(' secret'); + }); + + test('honors a configured header name', () => { + const stamp = stampStaticKey(new NameKeyCredential('x-api-key', 'secret'), { + headerName: 'X-Api-Key', + }); + expect(stamp.headerName).toBe('X-Api-Key'); + expect(stamp.headerValue).toBe('secret'); + }); + + test('treats NameKeyCredential uniformly with ApiKeyCredential -- only the secret is read, not .name', () => { + const stamp = stampStaticKey( + new NameKeyCredential('ignored-here', 'secret'), + ); + expect(stamp.headerName).toBe('Authorization'); + expect(stamp.headerValue).toBe('secret'); + }); + + test('is stateless -- the same credential stamps identically every call', () => { + const credential = new ApiKeyCredential('secret'); + expect(stampStaticKey(credential)).toEqual(stampStaticKey(credential)); + }); +}); diff --git a/packages/core/src/auth/static-key.ts b/packages/core/src/auth/static-key.ts new file mode 100644 index 0000000..7e8d480 --- /dev/null +++ b/packages/core/src/auth/static-key.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/auth/static-key.ts +import { + credentialKey, + type ApiKeyCredential, + type NameKeyCredential, +} from './credential.js'; + +/** + * Where and how a static key is written (AUTH-26). + * + * @internal + */ +export interface StaticKeyOptions { + /** The header to write. Defaults to `Authorization` (AUTH-26). */ + readonly headerName?: string | undefined; + /** A scheme prefix; when set it is written followed by exactly one space (AUTH-26). */ + readonly prefix?: string | undefined; +} + +/** + * The header name/value pair a static-key stamp produces. + * + * @internal + */ +export interface StaticKeyStamp { + /** The header to write. */ + readonly headerName: string; + /** The value to write, prefix already applied. */ + readonly headerValue: string; +} + +/** + * AUTH-26: writes the secret into the configured header, prefixed by the configured prefix and one + * space when set. + * + * Uniform over both credential shapes. `NameKeyCredential.name` is deliberately NOT consulted: it is + * non-secret metadata for the redacted `toString` in `credential.ts`, not a header name — a caller + * that wants the name to select the header passes it as `options.headerName`, explicitly. + * + * Stateless after construction, and no challenge is involved: a static key is stamped preemptively, + * never in reaction to a 401. + * + * @param credential - the API key or name-key credential to stamp. + * @param options - header name and prefix overrides. + * @returns the header name and value to write. + * + * @internal + */ +export function stampStaticKey( + credential: ApiKeyCredential | NameKeyCredential, + options?: StaticKeyOptions, +): StaticKeyStamp { + const headerName = options?.headerName ?? 'Authorization'; + // `credentialKey()`, not a public `credential.key` getter: AUTH-8's secret stays off the published + // surface and this module is the one sanctioned reader. + const key = credentialKey(credential); + const headerValue = + options?.prefix === undefined ? key : `${options.prefix} ${key}`; + return {headerName, headerValue}; +} diff --git a/packages/core/src/config/clock.ts b/packages/core/src/config/clock.ts index 5fd4e7b..f83290c 100644 --- a/packages/core/src/config/clock.ts +++ b/packages/core/src/config/clock.ts @@ -7,7 +7,7 @@ * (CFG-15/17 vs. 18) -- Node has no carrier threads to distinguish "block this one" from "schedule * that one" against, and every timer is already non-blocking. * - * @internal + * @public */ export interface Clock { /** Wall-clock epoch milliseconds. MAY move backwards; MUST NOT be used for elapsed-time math (CFG-16). */ diff --git a/packages/core/src/context/context.ts b/packages/core/src/context/context.ts index 99f6ce2..7a8a542 100644 --- a/packages/core/src/context/context.ts +++ b/packages/core/src/context/context.ts @@ -10,45 +10,59 @@ import { /** * Before any request (CTX-1). No `operationName` — CTX-16 introduces it at the request stage. * - * @internal + * @public */ export interface DispatchContext { + /** The discriminant. Branch on it to tell which promotion stage a step is observing. */ readonly kind: 'dispatch'; + /** This call's identity, unique per `Runtime.send()` and stable across every promotion (CTX-4/CTX-6). */ readonly key: symbol; + /** Correlation and tracing for this call, shared by reference across every promotion (CTX-2/CTX-3). */ readonly instrumentation: InstrumentationBundle; } /** * An outgoing request assembled (CTX-1). * - * @internal + * @public */ export interface RequestContext { + /** The discriminant. Branch on it to tell which promotion stage a step is observing. */ readonly kind: 'request'; + /** This call's identity, carried unchanged from the dispatch stage (CTX-4/CTX-6). */ readonly key: symbol; + /** Correlation and tracing for this call, carried by reference from the dispatch stage (CTX-2/CTX-3). */ readonly instrumentation: InstrumentationBundle; + /** The operation this call belongs to, or `undefined` when the caller named none (CTX-16). */ readonly operationName: string | undefined; + /** The assembled outbound request. Immutable; a step substitutes by passing a new one to `ctx.next`. */ readonly request: Request; } /** * A response arrived; terminal — no further promotion exists (CTX-1). * - * @internal + * @public */ export interface ExchangeContext { + /** The discriminant. Branch on it to tell which promotion stage a step is observing. */ readonly kind: 'exchange'; + /** This call's identity, carried unchanged from the dispatch stage (CTX-4/CTX-6). */ readonly key: symbol; + /** Correlation and tracing for this call, carried by reference from the dispatch stage (CTX-2/CTX-3). */ readonly instrumentation: InstrumentationBundle; + /** The operation this call belongs to, or `undefined` when the caller named none (CTX-16). */ readonly operationName: string | undefined; + /** The request that actually went on the wire — the substituted one, if a step replaced it (CTX-1). */ readonly request: Request; + /** The response that arrived. OPEN: whoever owns the drive owns closing its body. */ readonly response: Response; } /** * The three promotion-chain stages as one discriminated union, branched on `kind`. * - * @internal + * @public */ export type ExecutionContext = DispatchContext | RequestContext | ExchangeContext; diff --git a/packages/core/src/context/instrumentation.ts b/packages/core/src/context/instrumentation.ts index 8a14524..4bd4d15 100644 --- a/packages/core/src/context/instrumentation.ts +++ b/packages/core/src/context/instrumentation.ts @@ -2,21 +2,48 @@ // packages/core/src/context/instrumentation.ts /** - * Correlation/instrumentation bundle every execution context carries (CTX-14). `activeSpan` and - * `tracerFactory` are typed `unknown` rather than a Span/Tracer interface — nothing in this phase - * consumes either, and a real tracing adapter (deferred to Phase 7) owns their eventual shape. + * Correlation/instrumentation bundle every execution context carries (CTX-14), reachable from a + * custom step as `ctx.context.instrumentation`. * - * @internal + * **Two members are provisional.** `activeSpan` and `tracerFactory` are typed `unknown` rather than a + * Span/Tracer interface because nothing in the package consumes either yet, and the real tracing + * adapter — Phase 7a — owns their eventual shape. They are published as `unknown` deliberately, not + * accidentally: narrowing them later to a concrete Span/Tracer type is a widening of what a caller + * may pass and a narrowing of what they receive, so code that reads either today should treat it as + * opaque and re-check when 7a lands. Every other member below is stable. + * + * @public */ export interface InstrumentationBundle { + /** W3C trace-id, 32 lower-case hex characters. All-zero when tracing is disabled (CTX-15). */ readonly traceId: string; + /** W3C span-id, 16 lower-case hex characters. All-zero when tracing is disabled (CTX-15). */ readonly spanId: string; + /** W3C trace-flags byte; bit 0 is the sampled flag. `0` when tracing is disabled. */ readonly traceFlags: number; + /** W3C tracestate header value, verbatim. Empty when tracing is disabled. */ readonly traceState: string; + /** How `traceId`/`spanId` are encoded; `'none'` when tracing is disabled (CTX-15). */ readonly traceIdEncoding: string; + /** Whether this bundle carries a usable trace context. `false` for the disabled default (CTX-15). */ readonly isValid: boolean; + /** Whether the trace context was propagated in from a caller rather than started locally. */ readonly isRemote: boolean; + /** + * The span this call runs inside, or `undefined` when tracing is disabled. + * + * PROVISIONAL: typed `unknown` pending Phase 7a's tracing adapter — see this interface's own note. + */ readonly activeSpan: unknown; + /** + * Starts a child span for `operationName`. A no-op returning `undefined` when tracing is disabled. + * + * PROVISIONAL: the return type is `unknown` pending Phase 7a's tracing adapter — see this + * interface's own note. + * + * @param operationName - the operation to name the child span after. + * @returns the started span, or `undefined` when tracing is disabled. + */ readonly tracerFactory: (operationName: string) => unknown; } diff --git a/packages/core/src/http/request-options.test.ts b/packages/core/src/http/request-options.test.ts index 59ccd7a..33566c2 100644 --- a/packages/core/src/http/request-options.test.ts +++ b/packages/core/src/http/request-options.test.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/request-options.test.ts -// Exercises: HTTP-34 (EMPTY sentinel, defensive tag copy), HTTP-35 (timeout/maxRetries validation) +// Exercises: HTTP-34 (EMPTY sentinel, defensive tag copy), HTTP-35 (timeout/maxRetries validation), +// AUTH-4 (the per-call auth descriptor tier, added in Phase 5c) import {describe, expect, test} from 'bun:test'; +import {createAuthDescriptor} from '../auth/descriptor.js'; +import {createAuthRequirement} from '../auth/requirement.js'; import {RequestOptions} from './request-options.js'; import {RequestOptionsValidationError} from './errors.js'; @@ -13,6 +16,31 @@ describe('RequestOptions.EMPTY', () => { }); }); +describe('per-call auth descriptor (AUTH-4)', () => { + test('EMPTY carries no auth descriptor', () => { + expect(RequestOptions.EMPTY.auth).toBeUndefined(); + }); + + test('the builder stores and the accessor returns the same descriptor instance', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('NO_AUTH')]); + expect(RequestOptions.newBuilder().auth(descriptor).build().auth).toBe( + descriptor, + ); + }); + + test('an explicit undefined clears the override', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('NO_AUTH')]); + const builder = RequestOptions.newBuilder().auth(descriptor); + expect(builder.auth(undefined).build().auth).toBeUndefined(); + }); + + test('a derived builder carries the descriptor forward (HTTP-3)', () => { + const descriptor = createAuthDescriptor([createAuthRequirement('BASIC')]); + const original = RequestOptions.newBuilder().auth(descriptor).build(); + expect(original.newBuilder().build().auth).toBe(descriptor); + }); +}); + describe('timeout validation (HTTP-35)', () => { test('rejects zero or negative timeout', () => { expect(() => RequestOptions.newBuilder().timeoutMs(0)).toThrow( diff --git a/packages/core/src/http/request-options.ts b/packages/core/src/http/request-options.ts index 8670e5b..5ad89ab 100644 --- a/packages/core/src/http/request-options.ts +++ b/packages/core/src/http/request-options.ts @@ -1,20 +1,25 @@ // SPDX-License-Identifier: MIT // packages/core/src/http/request-options.ts +import type {AuthDescriptor} from '../auth/descriptor.js'; import type {Builder} from './builder.js'; import {RequestOptionsValidationError} from './errors.js'; +// eslint-disable-next-line max-params -- private, builder-internal plumbing; one parameter per HTTP-34 field let createRequestOptions: ( timeoutMs: number | undefined, maxRetries: number | undefined, tags: ReadonlyMap, + auth: AuthDescriptor | undefined, ) => RequestOptions; /** * Immutable per-call operational overrides that are deliberately *not* part of the wire form: a - * timeout, a max-retries count, and opaque string-keyed tags (HTTP-34). + * timeout, a max-retries count, opaque string-keyed tags (HTTP-34), and a per-call auth descriptor + * (AUTH-4). * - * Every field defaults to a "use the configured default" sentinel of `undefined`, and - * {@link RequestOptions.EMPTY} is the canonical override-nothing instance. + * Every scalar field defaults to a "use the configured default" sentinel of `undefined`; tags default + * to an empty map, which means the same thing. {@link RequestOptions.EMPTY} is the canonical + * override-nothing instance. * * `undefined` and `0` are different states for max-retries: `undefined` means "use the default", * while `0` means "disable retries for this call" (HTTP-35). @@ -25,28 +30,37 @@ export class RequestOptions { readonly #timeoutMs: number | undefined; readonly #maxRetries: number | undefined; readonly #tags: ReadonlyMap; + readonly #auth: AuthDescriptor | undefined; + // eslint-disable-next-line max-params -- private, builder-internal; one parameter per HTTP-34 field private constructor( timeoutMs: number | undefined, maxRetries: number | undefined, tags: ReadonlyMap, + auth: AuthDescriptor | undefined, ) { this.#timeoutMs = timeoutMs; this.#maxRetries = maxRetries; this.#tags = tags; + this.#auth = auth; Object.freeze(this); } static { - createRequestOptions = (timeoutMs, maxRetries, tags) => - new RequestOptions(timeoutMs, maxRetries, tags); + // eslint-disable-next-line max-params -- private, builder-internal plumbing; one parameter per HTTP-34 field + createRequestOptions = (timeoutMs, maxRetries, tags, auth) => + new RequestOptions(timeoutMs, maxRetries, tags, auth); } - /** The canonical "override nothing" instance: no timeout, no retry override, no tags. */ + /** + * The canonical "override nothing" instance: no timeout, no retry override, no tags, no per-call + * auth descriptor. + */ static readonly EMPTY = new RequestOptions( undefined, undefined, Object.freeze(new Map()), + undefined, ); /** @@ -68,7 +82,8 @@ export class RequestOptions { return new RequestOptionsBuilder() .timeoutMs(this.#timeoutMs) .maxRetries(this.#maxRetries) - .tags(this.#tags); + .tags(this.#tags) + .auth(this.#auth); } /** The per-call timeout in milliseconds, or `undefined` to use the configured default. */ @@ -93,6 +108,20 @@ export class RequestOptions { tag(key: string): string | undefined { return this.#tags.get(key); } + + /** + * The per-call auth descriptor, or `undefined` to use the configured tiers. + * + * Fills AUTH-4's most-specific `perCall` tier: when present it wins over any `perCall`, `operation`, + * or `client` descriptor the AUTH pillar step was constructed with, and a tier below it is never + * consulted even if this one turns out to be unsatisfiable. + * + * Returned by reference: an {@link AuthDescriptor} is frozen at construction, so there is nothing to + * copy defensively. + */ + get auth(): AuthDescriptor | undefined { + return this.#auth; + } } /** @@ -107,6 +136,7 @@ export class RequestOptionsBuilder implements Builder { #timeoutMs: number | undefined; #maxRetries: number | undefined; readonly #tags = new Map(); + #auth: AuthDescriptor | undefined; /** * Sets the per-call timeout. @@ -130,10 +160,6 @@ export class RequestOptionsBuilder implements Builder { /** * Sets the per-call retry ceiling. * - * @param value - the maximum retries, or `undefined` for no override. `0` is accepted and means - * "disable retries for this call"; anything that is not a non-negative integer is rejected rather - * than silently reinterpreted (HTTP-35). - * * The range check is deliberately wider than "not negative". A retry ceiling is a count of wire * sends, so `Infinity` and `NaN` are as out-of-range as `-1` -- and they are worse in effect: a * negative value at least fails a downstream lower-bound guard, while a non-finite one makes a @@ -141,6 +167,9 @@ export class RequestOptionsBuilder implements Builder { * HTTP-35's point is that an out-of-range retry count is a loud error at the call site that * supplied it, never a value reinterpreted somewhere downstream. * + * @param value - the maximum retries, or `undefined` for no override. `0` is accepted and means + * "disable retries for this call"; anything that is not a non-negative integer is rejected rather + * than silently reinterpreted (HTTP-35). * @returns this builder, for chaining. * @throws {@link RequestOptionsValidationError} when a defined value is negative, fractional, or * not finite. @@ -166,6 +195,20 @@ export class RequestOptionsBuilder implements Builder { return this; } + /** + * Sets the per-call auth descriptor, filling AUTH-4's `perCall` tier for this call only. + * + * No validation beyond the type: {@link createAuthDescriptor} already rejects an empty requirement + * list and freezes the result (AUTH-3), so any constructed descriptor is valid by construction. + * + * @param descriptor - the descriptor, or `undefined` for no override. + * @returns this builder, for chaining. + */ + auth(descriptor: AuthDescriptor | undefined): this { + this.#auth = descriptor; + return this; + } + /** * Copies and freezes the accumulated state into an immutable {@link RequestOptions}. * @@ -179,6 +222,7 @@ export class RequestOptionsBuilder implements Builder { this.#timeoutMs, this.#maxRetries, Object.freeze(new Map(this.#tags)), + this.#auth, ); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a522929..8345c74 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,11 +1,18 @@ // SPDX-License-Identifier: MIT // packages/core/src/index.ts /** - * The immutable, transport-agnostic HTTP domain model at the heart of `@dexpace/core`. + * The transport-agnostic HTTP core of `@dexpace/core`: an immutable domain model, and the pipeline + * that drives it. * - * Every type here is frozen at construction and built through a builder or a static factory, so - * case-insensitivity, multi-value semantics, ordering, header-injection defenses, method/body - * legality, and total status handling are fixed once and behave identically under every transport. + * Every DOMAIN MODEL type — requests, responses, headers, bodies, status — is frozen at construction + * and reachable only through a builder or a static factory, so case-insensitivity, multi-value + * semantics, ordering, header-injection defenses, method/body legality, and total status handling are + * fixed once and behave identically under every transport. + * + * The PIPELINE surface promoted in Phase 5c is deliberately not held to that rule. `PipelineBuilder` + * is mutable by design and freezes only at `build()`; `Stage`, `Step`, `Next`, `StepContext`, and the + * settings records are plain types a caller writes literals for; `authStep`, `retryStep`, + * `redirectStep`, and `standardResilience` are factories returning descriptors and runtimes. * * The package has zero runtime dependencies. * @@ -59,3 +66,87 @@ export { } from './body/simple-bodies.js'; export {streamBody, type StreamBody} from './body/stream-body.js'; export {TypedResponse} from './body/typed-response.js'; + +// --------------------------------------------------------------------------------------------- +// The pillar-authoring surface, promoted in Phase 5c. +// +// 5c is the first point a caller can assemble a genuinely working pipeline -- all three resilience +// pillars plus the preset now exist. Promoting any earlier would have frozen shapes 5c still had +// latitude to reshape, which is why every prior phase deliberately exported nothing from here. +// --------------------------------------------------------------------------------------------- + +// Group 1: the authoring surface itself. +export type {Stage} from './pipeline/stage.js'; +export {PILLAR_STAGES, STAGE_ORDER} from './pipeline/stage.js'; +export type {Next, Step, StepContext, StepDescriptor} from './pipeline/step.js'; +export {PipelineBuilder} from './pipeline/builder.js'; +export {Runtime} from './pipeline/runtime.js'; +export {retryStep} from './retry/retry-step.js'; +export {redirectStep} from './redirect/redirect-step.js'; +export {authStep} from './auth/auth-step.js'; +export {standardResilience} from './auth/preset.js'; + +// Group 2: everything Group 1's signatures name. A promoted function whose parameter type is +// internal-only is an API a caller cannot call, and api-extractor reports each omission as +// `ae-forgotten-export`. +// +// The word "internal-only" above is deliberate and must not be spelled as the TSDoc tag: gts turns +// `stripInternal` on, and TypeScript tests the WHOLE leading comment range of a declaration for that +// tag as a substring -- so writing it in prose here silently deletes the export below from the +// emitted `.d.ts`. It did, for one commit. `api-extractor.json` now fails `api:ci` on the resulting +// `ae-forgotten-export`, and `verify:consumer-types` compiles these four names from the built +// package, so the same slip cannot ship twice. +// The whole context family, not just `ExecutionContext`: it is a union alias, and `StepContext.context` +// makes every member reachable from a promoted signature. A caller writing a custom step reads +// `ctx.context.kind` to tell which promotion stage it is in. +export type { + DispatchContext, + ExchangeContext, + ExecutionContext, + RequestContext, +} from './context/context.js'; +export type {InstrumentationBundle} from './context/instrumentation.js'; +export type {Clock} from './config/clock.js'; +export type {BackoffSettings} from './retry/backoff.js'; +export type {RetrySettings} from './retry/settings.js'; +export type {RetryStepOptions} from './retry/retry-step.js'; +export type { + RedirectCondition, + RedirectPredicate, + RedirectSettings, +} from './redirect/settings.js'; +export type {StandardResilienceOptions} from './auth/preset.js'; +export type { + ApiKeyCredentialConfig, + AuthCredentialSet, + AuthStepSettings, + BasicCredential, + BearerCredential, + ChallengeHook, + DigestCredential, +} from './auth/auth-step.js'; +export type {AuthTiers} from './auth/resolve.js'; +export type {AuthScheme} from './auth/scheme.js'; +export type {DigestAlgorithm} from './auth/digest.js'; + +// Factories, not bare interfaces: AUTH-3 validates and freezes inside `createAuthDescriptor`, and +// `ApiKeyCredential`/`NameKeyCredential`/`BearerToken` are NOMINAL -- they carry a `#` field, so no +// caller-side object literal is assignable and the AUTH-9 validation in each factory cannot be routed +// around. Without these, API_KEY and OAUTH2 auth are unreachable from outside the package. +// `BearerToken` is a VALUE export, not a type-only one: it is a class, and `TokenProvider` returns it. +export type {AuthDescriptor} from './auth/descriptor.js'; +export {createAuthDescriptor} from './auth/descriptor.js'; +export type {AuthRequirement} from './auth/requirement.js'; +export { + authRequirementsEqual, + createAuthRequirement, +} from './auth/requirement.js'; +export type {TokenProvider} from './auth/credential.js'; +export { + ApiKeyCredential, + BearerToken, + NameKeyCredential, + bearerTokensEqual, + createBearerToken, +} from './auth/credential.js'; +export {AuthResolutionError, PlaintextCredentialError} from './auth/errors.js'; diff --git a/packages/core/src/pipeline/builder.test.ts b/packages/core/src/pipeline/builder.test.ts index bea746c..1cdaa87 100644 --- a/packages/core/src/pipeline/builder.test.ts +++ b/packages/core/src/pipeline/builder.test.ts @@ -7,7 +7,8 @@ // (a missing anchor fails), PIPE-22 (an edit sequence flattens the same as constructing the final set from // scratch), PIPE-23 (a colliding reload leaves prior content untouched, and a same-type pillar repeat inside // one batch seats only one step), PIPE-25 (flatten order), PIPE-38 (appendAll preserves batch order; -// prependAll reverses it), PIPE-1/PIPE-2 (a built pipeline, driven: entry in STAGE_ORDER, exit reversed) +// prependAll reverses it), PIPE-1/PIPE-2 (a built pipeline, driven: entry in STAGE_ORDER, exit reversed), +// PIPE-35 (seedFrom's explicit, non-defaulted flatten-vs-nest modes) import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; import {Protocol} from '../http/protocol.js'; @@ -434,3 +435,113 @@ describe('PipelineBuilder batch-order properties (PIPE-38)', () => { ); }); }); + +// Module-scope, not describe-local: the seedFrom suite is split across sibling describes to stay +// inside `max-lines-per-function`, and both halves need these. +class RecordingTransport implements Transport { + readonly calls: Request[] = []; + + send(request: Request): Promise { + this.calls.push(request); + return Promise.resolve(aResponse(200)); + } + + close(): Promise { + return Promise.resolve(); + } +} + +function probeStep( + label: string, + stage: StepDescriptor['stage'], + order: string[], +): StepDescriptor { + return { + type: Symbol(label), + stage, + // A plain pass-through probe never re-drives, so `next()` suffices -- no fork needed. + fn: async (request, ctx) => { + order.push(label); + return ctx.next(request); + }, + }; +} + +describe('PipelineBuilder.seedFrom (PIPE-35)', () => { + test('flatten: seeded steps run in the SAME pass as newly appended ones, reusing the original transport', async () => { + const transport = new RecordingTransport(); + const order: string[] = []; + const seeded = new PipelineBuilder(transport) + .append(probeStep('seeded', 'LOGGING', order)) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'flatten') + .append(probeStep('appended', 'SERDE', order)) + .build(); + + await runtime.send(aRequest('https://example.com')); + + expect(order).toEqual(['seeded', 'appended']); // one combined STAGE_ORDER pass + // The ORIGINAL transport is the terminal -- `seeded` itself is not in the chain. + expect(transport.calls).toHaveLength(1); + }); + + test('flatten: re-buckets each descriptor by its OWN stage, not by seeded array position', () => { + const transport = new RecordingTransport(); + const order: string[] = []; + const seeded = new PipelineBuilder(transport) + .append(probeStep('late', 'SERDE', order)) + .append(probeStep('early', 'PRE_REDIRECT', order)) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'flatten').build(); + + expect(labelsOf(runtime)).toEqual(['early', 'late']); + }); + + test('flatten: pillar-collision rules apply exactly as any other append sequence', () => { + const transport = new RecordingTransport(); + const seeded = new PipelineBuilder(transport) + .append(descriptor('retry-a', 'RETRY')) + .build(); + + expect(() => + PipelineBuilder.seedFrom(seeded, 'flatten').append( + descriptor('retry-b', 'RETRY'), + ), + ).toThrow(PillarCollisionError); + }); +}); + +describe('PipelineBuilder.seedFrom nest mode (PIPE-35)', () => { + test('nest: the seeded runtime is an opaque Transport -- its steps run in a separate, inner pass', async () => { + const transport = new RecordingTransport(); + const order: string[] = []; + const seeded = new PipelineBuilder(transport) + .append(probeStep('inner', 'LOGGING', order)) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'nest') + .append(probeStep('outer', 'LOGGING', order)) + .build(); + + await runtime.send(aRequest('https://example.com')); + + expect(order).toEqual(['outer', 'inner']); // the outer step runs BEFORE the nested runtime's + expect(transport.calls).toHaveLength(1); // still exactly one wire send at the bottom + }); + + test('nest: the same pillar may be occupied in BOTH layers -- they are separate builders', () => { + const transport = new RecordingTransport(); + const seeded = new PipelineBuilder(transport) + .append(descriptor('retry-inner', 'RETRY')) + .build(); + + const runtime = PipelineBuilder.seedFrom(seeded, 'nest') + .append(descriptor('retry-outer', 'RETRY')) + .build(); + + expect(labelsOf(runtime)).toEqual(['retry-outer']); + expect(runtime.transport).toBe(seeded); + }); +}); diff --git a/packages/core/src/pipeline/builder.ts b/packages/core/src/pipeline/builder.ts index 537d2ec..5216fd2 100644 --- a/packages/core/src/pipeline/builder.ts +++ b/packages/core/src/pipeline/builder.ts @@ -8,7 +8,7 @@ import { PillarCollisionError, ReservedStageError, } from './errors.js'; -import {Runtime} from './runtime.js'; +import {createRuntime, type Runtime} from './runtime.js'; import {PILLAR_STAGES, STAGE_ORDER, type Stage} from './stage.js'; import type {StepDescriptor} from './step.js'; @@ -21,7 +21,7 @@ interface AnchorLocation { * Assembles a stage-based pipeline via surgical edits (PIPE-7, PIPE-18..PIPE-24), flattening into an * immutable Runtime at build() time (PIPE-25). Mutable while being built; the produced Runtime is frozen. * - * @internal + * @public */ export class PipelineBuilder { readonly #buckets = new Map(); @@ -174,32 +174,74 @@ export class PipelineBuilder { reload(descriptors: readonly StepDescriptor[]): this { const admitted: StepDescriptor[] = []; const pillarTypes = new Map(); - for (const desc of descriptors) { - this.#rejectReservedStage(desc.stage, 'reload'); - if (!PILLAR_STAGES.has(desc.stage)) { - admitted.push(desc); + for (const descriptor of descriptors) { + this.#rejectReservedStage(descriptor.stage, 'reload'); + if (!PILLAR_STAGES.has(descriptor.stage)) { + admitted.push(descriptor); continue; } - const seenType = pillarTypes.get(desc.stage); - if (seenType === desc.type) continue; // PIPE-6: a repeat of the SAME type is idempotent, not a second step. + const seenType = pillarTypes.get(descriptor.stage); + // PIPE-6: a repeat of the SAME type is idempotent, not a second step. + if (seenType === descriptor.type) continue; if (seenType !== undefined) { - throw new PillarCollisionError(desc.stage, seenType, desc.type); // PIPE-5 + throw new PillarCollisionError( + descriptor.stage, + seenType, + descriptor.type, + ); // PIPE-5 } - pillarTypes.set(desc.stage, desc.type); - admitted.push(desc); + pillarTypes.set(descriptor.stage, descriptor.type); + admitted.push(descriptor); } // PIPE-4: `admitted` holds at most one entry per pillar stage by construction -- a same-type repeat was // skipped above rather than pushed, so a batch cannot install two steps onto one pillar the way the // incremental `append` path already refuses to. this.#buckets.clear(); - for (const desc of admitted) { - const bucket = this.#buckets.get(desc.stage); - if (bucket === undefined) this.#buckets.set(desc.stage, [desc]); - else bucket.push(desc); + for (const descriptor of admitted) { + const bucket = this.#buckets.get(descriptor.stage); + if (bucket === undefined) + this.#buckets.set(descriptor.stage, [descriptor]); + else bucket.push(descriptor); } return this; } + /** + * PIPE-35: derives a builder from an already-built `runtime`, under an explicit, non-defaulted + * `mode` — the requirement's own MUST is that the flatten-vs-nest choice be explicit, never + * accidental, so there is deliberately no default value. + * + * `flatten` re-buckets every seeded descriptor by its own stage and reuses `runtime`'s transport as + * the new builder's terminal, so seeded and newly-appended steps run in the SAME cursor pass. + * Pillar-collision rules apply exactly as they would to any other `append` sequence, because + * flatten IS an append sequence. + * + * Seeding re-seats the SAME descriptor objects, never copies: a `StepDescriptor` is a plain record + * around a closure, so any state that closure captured is now shared between `runtime` and the + * builder derived from it. `authStep`'s `BearerTokenCache` is the live example — a flattened + * builder shares one token cache, and therefore one single-flight slot, with the runtime it was + * seeded from. That is usually what a caller wants (AUTH-34's coalescing only works when concurrent + * calls meet at one instance), but it is sharing, not isolation; a caller who needs an independent + * cache constructs a fresh `authStep`. + * + * `nest` constructs a fresh builder whose transport IS `runtime`, treated as an opaque `Transport` + * — `Runtime implements Transport` (PIPE-26) makes this work with zero adapter code — so the new + * builder's own steps run once, outside `runtime`'s already-flattened loops. + * + * @param runtime - the built pipeline to seed from. + * @param mode - `'flatten'` to merge its steps into this builder's stages, `'nest'` to wrap it as + * this builder's transport. + * @returns a fresh builder seeded per `mode`. + * @throws PillarCollisionError in `'flatten'` mode when two seeded descriptors of different types + * claim one pillar stage (PIPE-5) — the same rule `append` enforces. + */ + static seedFrom(runtime: Runtime, mode: 'flatten' | 'nest'): PipelineBuilder { + if (mode === 'flatten') { + return new PipelineBuilder(runtime.transport).appendAll(runtime.steps); + } + return new PipelineBuilder(runtime); + } + /** PIPE-25: flattens stage buckets in declaration order, skipping SEND, into an immutable Runtime. */ build(): Runtime { const flattened: StepDescriptor[] = []; @@ -208,7 +250,7 @@ export class PipelineBuilder { const bucket = this.#buckets.get(stage); if (bucket !== undefined) flattened.push(...bucket); } - return new Runtime(flattened, this.#transport); // Runtime copies and freezes -- PIPE-10/PIPE-25. + return createRuntime(flattened, this.#transport); // Runtime copies and freezes -- PIPE-10/PIPE-25. } #rejectReservedStage(stage: Stage, operation: string): void { diff --git a/packages/core/src/pipeline/runtime.test.ts b/packages/core/src/pipeline/runtime.test.ts index 1b9a346..2d7cab7 100644 --- a/packages/core/src/pipeline/runtime.test.ts +++ b/packages/core/src/pipeline/runtime.test.ts @@ -22,7 +22,7 @@ import {Response} from '../http/response.js'; import {Status} from '../http/status.js'; import {invariant} from '../invariant.js'; import type {Transport} from '../seams/transport.js'; -import {exchangeSource, Runtime} from './runtime.js'; +import {createRuntime, exchangeSource} from './runtime.js'; import type {Step, StepDescriptor} from './step.js'; function aRequest(url: string): Request { @@ -73,7 +73,7 @@ describe('Runtime.send empty pipeline (PIPE-9)', () => { test('dispatches directly to the terminal transport, threading options and signal, no context installed', async () => { const canned = aResponse(200); const transport = new RecordingTransport(canned); - const runtime = new Runtime([], transport); + const runtime = createRuntime([], transport); const request = aRequest('https://example.com/a'); const signal = new AbortController().signal; const sizeBefore = contextStore.size; @@ -100,7 +100,7 @@ describe('Runtime.send context-store wiring (CTX-17, CTX-8)', () => { stage: 'PRE_LOGGING', fn: step, }; - const runtime = new Runtime( + const runtime = createRuntime( [descriptor], new RecordingTransport(aResponse(200)), ); @@ -129,7 +129,7 @@ describe('Runtime.send context-store wiring (CTX-17, CTX-8)', () => { stage: 'PRE_LOGGING', fn: step, }; - const runtime = new Runtime( + const runtime = createRuntime( [descriptor], new RecordingTransport(aResponse(200)), ); @@ -188,7 +188,7 @@ describe('Runtime.send request substitution reaches the wire (PIPE-14)', () => { }; const transport = new RecordingTransport(aResponse(200)); - await new Runtime([descriptor], transport).send(original); + await createRuntime([descriptor], transport).send(original); expect(transport.calls[0]?.request).toBe(substituted); }); @@ -206,7 +206,7 @@ describe('Runtime concurrency (PIPE-10, PIPE-11)', () => { stage: 'PRE_LOGGING', fn: rewrite, }; - const runtime = new Runtime([descriptor], transport); + const runtime = createRuntime([descriptor], transport); await Promise.all([ runtime.send(aRequest('https://example.com/a/')), @@ -229,7 +229,7 @@ describe('Runtime.steps (PIPE-25)', () => { stage: 'PRE_LOGGING', fn: async (_r, ctx) => ctx.next(), }; - const runtime = new Runtime( + const runtime = createRuntime( [descriptor], new RecordingTransport(aResponse(200)), ); @@ -238,7 +238,7 @@ describe('Runtime.steps (PIPE-25)', () => { }); test('the exposed view is frozen', () => { - const runtime = new Runtime([], new RecordingTransport(aResponse(200))); + const runtime = createRuntime([], new RecordingTransport(aResponse(200))); expect(Object.isFrozen(runtime.steps)).toBe(true); }); @@ -250,7 +250,10 @@ describe('Runtime.steps (PIPE-25)', () => { fn: async (_r, ctx) => ctx.next(), }; const source: StepDescriptor[] = [descriptor]; - const runtime = new Runtime(source, new RecordingTransport(aResponse(200))); + const runtime = createRuntime( + source, + new RecordingTransport(aResponse(200)), + ); source.push({...descriptor, type: Symbol('smuggled')}); @@ -271,11 +274,11 @@ describe('Runtime as a nested transport (PIPE-26)', () => { log.push(`exit:${label}`); return response; }; - const inner = new Runtime( + const inner = createRuntime( [{type: Symbol('inner'), stage: 'PRE_SERDE', fn: probe('inner')}], transport, ); - const outer = new Runtime( + const outer = createRuntime( [{type: Symbol('outer'), stage: 'PRE_REDIRECT', fn: probe('outer')}], inner, ); @@ -300,7 +303,7 @@ describe('Runtime as a nested transport (PIPE-26)', () => { describe('Runtime.close (PIPE-27)', () => { test('never calls the underlying transport close', async () => { const transport = new RecordingTransport(aResponse(200)); - const runtime = new Runtime([], transport); + const runtime = createRuntime([], transport); await runtime.close(); diff --git a/packages/core/src/pipeline/runtime.ts b/packages/core/src/pipeline/runtime.ts index 06a4e91..786e3c7 100644 --- a/packages/core/src/pipeline/runtime.ts +++ b/packages/core/src/pipeline/runtime.ts @@ -24,7 +24,7 @@ import type {StepDescriptor} from './step.js'; * never left the process, against CTX-1's "the exchange stage exposes the request and the response". Doing it * here rather than widening `promoteToExchange` with a request-override keeps promotion strictly additive. * - * Exported (still `@internal`, still absent from the package barrel) so its two branches can be asserted as + * Exported (still internal-only, still absent from the package barrel) so its two branches can be asserted as * the pure function they are. The alternative -- observing the exchange context end-to-end -- would require * patching `install` on the process-wide `contextStore` singleton, since `send()` evicts the entry in its own * `finally`. @@ -43,26 +43,46 @@ export function exchangeSource( }); } +/** + * TypeScript has no friend classes, so `PipelineBuilder` -- a different module -- reaches `Runtime`'s + * private constructor through this module-scoped `let`, assigned exactly once inside the class's + * `static {}` block. Init-once wiring, not mutable state, the same shape every builder-based model in + * `src/http/` uses (`createHeaders`, `createRequest`, ...). It is surfaced as {@link createRuntime} + * rather than kept module-local because the sanctioned construction site lives in another file. + */ +let create: (steps: readonly StepDescriptor[], transport: Transport) => Runtime; + /** * The built, immutable pipeline (PIPE-10, PIPE-25). Implements `Transport` itself (PIPE-26) -- Phase 2's * `Transport` SPI has one method (`send`), so there is no second `sendAsync` entry point to delegate through. * `close()` deliberately never touches the wrapped transport (PIPE-27): the pipeline never owns it. * - * @internal + * The constructor is TS-`private`, so no field-wise constructor appears in the emitted `.d.ts` and a + * consumer cannot assemble a `Runtime` around `PipelineBuilder.build()`'s validation. That matters + * now that this class is public surface: a hand-built `new Runtime([authStep(a), authStep(b)], t)` + * would put two steps in the single AUTH pillar slot (PIPE-4/PIPE-5, AUTH-27) and a hand-ordered step + * array would invert PIPE-2's pillar precedence chain, both without any collision error, because + * `Cursor` runs whatever array it is handed. `PipelineBuilder` is the only path that enforces either. + * + * @public */ export class Runtime implements Transport { readonly #steps: readonly StepDescriptor[]; readonly #transport: Transport; - constructor(steps: readonly StepDescriptor[], transport: Transport) { + private constructor(steps: readonly StepDescriptor[], transport: Transport) { // PIPE-10/PIPE-25: the built runtime is immutable, and `get steps()` hands out a read-only view. Copying - // and freezing here rather than trusting the caller makes both structural -- `PipelineBuilder` is not the - // only construction site (tests build one directly, and Phase 5+ may too), so an unfrozen array passed in - // would leave the "immutable after construction" guarantee resting on caller discipline. + // and freezing here rather than trusting the caller makes both structural -- `createRuntime` is reachable + // from any in-package caller, so an unfrozen array passed in would leave the "immutable after + // construction" guarantee resting on caller discipline. this.#steps = Object.freeze([...steps]); this.#transport = transport; } + static { + create = (steps, transport) => new Runtime(steps, transport); + } + /** * Drives `request` through the flattened step array and on to the wrapped transport, installing this * call's `ExecutionContext` in the store for the duration of the drive and evicting it again on both @@ -114,11 +134,62 @@ export class Runtime implements Transport { } } + /** + * A no-op, deliberately (PIPE-27). The pipeline never OWNS its terminal transport, so closing the + * runtime must not close the transport a caller handed it and may still be using elsewhere. The + * method exists only to satisfy the `Transport` SPI, so a `Runtime` can be nested as another + * pipeline's transport (PIPE-26) without the outer one leaking a close through. + * + * @returns a promise that is already resolved. + */ async close(): Promise { // PIPE-27: the pipeline never owns its transport and MUST NOT close it. } + /** + * The flattened step array, in the order the cursor drives it (PIPE-25). + * + * Frozen at construction, so the returned array is a read-only view and not a defensive copy — + * there is nothing a caller can mutate through it. + * + * @returns the ordered, immutable step array. + */ get steps(): readonly StepDescriptor[] { return this.#steps; // PIPE-25: "exposes a read-only, ordered view of its steps." } + + /** + * The wrapped terminal transport. + * + * Exposed for `PipelineBuilder.seedFrom(runtime, 'flatten')` (PIPE-35), which must reuse this + * runtime's own transport as the seeded builder's terminal — flatten mode is not implementable + * without it. Read-only: the pipeline never owns its transport (PIPE-27), so there is nothing to + * copy defensively and nothing a caller can change by holding the reference. + * + * @returns the transport this pipeline dispatches to innermost. + */ + get transport(): Transport { + return this.#transport; + } +} + +/** + * The in-package construction hook for {@link Runtime}, whose own constructor is `private` so no + * consumer can build one around `PipelineBuilder.build()`'s pillar and ordering validation. + * + * Exported (still internal-only, still absent from the package barrel) for the same reason + * {@link exchangeSource} is: the sanctioned caller -- `PipelineBuilder.build()` -- lives in a + * different module, and TypeScript has no friend-class visibility to express that with. + * + * @param steps - the flattened, stage-ordered step array. Copied and frozen. + * @param transport - the terminal transport. Never closed by the pipeline (PIPE-27). + * @returns the built, immutable runtime. + * + * @internal + */ +export function createRuntime( + steps: readonly StepDescriptor[], + transport: Transport, +): Runtime { + return create(steps, transport); } diff --git a/packages/core/src/pipeline/stage.ts b/packages/core/src/pipeline/stage.ts index 28f3272..3b1599c 100644 --- a/packages/core/src/pipeline/stage.ts +++ b/packages/core/src/pipeline/stage.ts @@ -8,7 +8,7 @@ * SHOULD extension slots around every pillar. `SEND` is terminal and reserved -- PIPE-8, flattening skips it * and `PipelineBuilder` rejects any attempt to install a step there. * - * @internal + * @public */ export type Stage = | 'PRE_REDIRECT' @@ -33,7 +33,7 @@ export type Stage = * further stage later is one splice here -- no existing `Stage` value needs to change, so there is no * numeric-gap "renumbering" concern to design around. * - * @internal + * @public */ export const STAGE_ORDER: readonly Stage[] = [ 'PRE_REDIRECT', @@ -54,7 +54,7 @@ export const STAGE_ORDER: readonly Stage[] = [ 'SEND', ]; -/** A pillar stage admits at most one step (PIPE-4). @internal */ +/** A pillar stage admits at most one step (PIPE-4). @public */ export const PILLAR_STAGES: ReadonlySet = new Set([ 'REDIRECT', 'RETRY', diff --git a/packages/core/src/pipeline/step.ts b/packages/core/src/pipeline/step.ts index e1fa453..f924572 100644 --- a/packages/core/src/pipeline/step.ts +++ b/packages/core/src/pipeline/step.ts @@ -18,7 +18,7 @@ import type {Stage} from './stage.js'; * @throws CursorAlreadyAdvancedError -- as a rejected promise -- when an already-invoked handle is * invoked a second time (PIPE-11/PIPE-15). * - * @internal + * @public */ export type Next = (request?: Request) => Promise; @@ -26,11 +26,29 @@ export type Next = (request?: Request) => Promise; * What a step receives on each invocation (PIPE-12). `fork` is present only when the invoking step occupies * a pillar stage (PIPE-15/16); an ordinary step's `ctx.fork` is `undefined`. * - * @internal + * @public */ export interface StepContext { + /** + * Advances the chain exactly once (PIPE-14/PIPE-15). The ordinary way a step delegates downstream; + * a step that never calls it short-circuits the rest of the pipeline. + */ readonly next: Next; + /** + * Mints a FRESH one-shot continuation, so a pillar step can drive the downstream chain more than + * once — retry's attempts, redirect's hops, auth's challenge replay (PIPE-15/PIPE-16). + * + * Present only when the invoking step occupies a pillar stage; `undefined` for an ordinary step. A + * step that forks more than once owns closing whatever response its own prior fork produced before + * forking again (PIPE-40). + */ readonly fork?: (() => Next) | undefined; + /** + * This call's execution context, at whichever promotion stage the drive has reached (CTX-1). + * Branch on `context.kind` to tell which: `'dispatch'` before a request exists, `'request'` once + * one is assembled, `'exchange'` once a response has arrived. Shared by reference across every + * fork, so it is the same object on every attempt and every hop. + */ readonly context: ExecutionContext; /** * The call's cancellation signal, threaded from the cursor (PIPE-13). Undefined when the caller @@ -54,7 +72,7 @@ export interface StepContext { * A step that forks more than once owns closing whatever response its own prior fork produced before * invoking `fork()` again (PIPE-40) -- that responsibility sits on the wrapping step, not on `Cursor`. * - * @internal + * @public */ export type Step = (request: Request, ctx: StepContext) => Promise; @@ -62,10 +80,17 @@ export type Step = (request: Request, ctx: StepContext) => Promise; * A registered step: its function plus the identity (`type`) PIPE-6's reference-identity pillar check and * PIPE-18/19's anchor-type matching both key off, and the `stage` it occupies. * - * @internal + * @public */ export interface StepDescriptor { + /** + * This step's identity. PIPE-6's pillar-occupancy check and PIPE-18/PIPE-19's anchor matching both + * compare it by REFERENCE, so a factory must mint one module-level symbol and reuse it across every + * descriptor it produces — never a fresh `Symbol()` per call. + */ readonly type: symbol; + /** The stage this step occupies. A pillar stage admits at most one step (PIPE-4/PIPE-5). */ readonly stage: Stage; + /** The step itself (PIPE-12). */ readonly fn: Step; } diff --git a/packages/core/src/redirect/redirect-step.ts b/packages/core/src/redirect/redirect-step.ts index 1674448..a53b9ee 100644 --- a/packages/core/src/redirect/redirect-step.ts +++ b/packages/core/src/redirect/redirect-step.ts @@ -87,7 +87,7 @@ async function decideOrClose( * @param overrides - redirect policy overrides; a zero-argument call yields the spec defaults. * @returns the descriptor to install in a pipeline's REDIRECT slot. * - * @internal + * @public */ export function redirectStep( overrides?: Partial, diff --git a/packages/core/src/redirect/settings.ts b/packages/core/src/redirect/settings.ts index 31b7065..2e217fc 100644 --- a/packages/core/src/redirect/settings.ts +++ b/packages/core/src/redirect/settings.ts @@ -12,11 +12,14 @@ import {DEFAULT_ALLOWED_METHODS} from './codes.js'; * * `visited` is insertion-ordered and includes the current request's URI. * - * @internal + * @public */ export interface RedirectCondition { + /** The 3xx response being judged. Open; the predicate MUST NOT consume or close its body. */ readonly response: Response; + /** How many hops this call has already followed, before the one under consideration. */ readonly redirectsFollowed: number; + /** Every URI seen on this call, insertion-ordered, including the current request's (REDIR-19). */ readonly visited: ReadonlySet; } @@ -25,18 +28,22 @@ export interface RedirectCondition { * wire-safety mechanics that follow it -- credential stripping, the downgrade guard, body replayability, * loop and hop-cap detection -- see `decide.ts`'s note on the scope of that override. * - * @internal + * @public */ export type RedirectPredicate = ( condition: Readonly, ) => boolean; /** - * Redirect policy. Every field is optional at the construction surface ({@link redirectSettings} takes a - * `Partial`), so a zero-config call yields the spec defaults and a caller can override one field without - * restating the rest. + * Redirect policy. Every field is optional at the construction surface -- the internal + * `redirectSettings()` factory takes a `Partial` -- so a zero-config call yields the spec defaults and a + * caller can override one field without restating the rest. `redirectStep()` and `standardResilience()` + * both accept that same `Partial`. * - * @internal + * The factory is named in plain prose rather than as a TSDoc link: it is internal and absent from the + * package barrel, so a link to it cannot resolve from a published declaration. + * + * @public */ export interface RedirectSettings { /** REDIR-17: a non-negative integer, default 3. `0` disables following, with no special branch anywhere downstream. */ diff --git a/packages/core/src/retry/backoff.ts b/packages/core/src/retry/backoff.ts index 1f01414..63f8700 100644 --- a/packages/core/src/retry/backoff.ts +++ b/packages/core/src/retry/backoff.ts @@ -6,11 +6,14 @@ import {invariant} from '../invariant.js'; * The pure-math half of the retry schedule (RETRY-9..RETRY-11, RETRY-43). Carried inside * `RetrySettings`, never constructed standalone by a caller. * - * @internal + * @public */ export interface BackoffSettings { + /** The first attempt's delay in milliseconds, before any multiplier or jitter (RETRY-9). */ readonly initialDelayMs: number; + /** The exponential growth factor applied per attempt: delay(n) = initialDelayMs * multiplier^n (RETRY-9). */ readonly multiplier: number; + /** The ceiling the exponential schedule saturates at, in milliseconds (RETRY-11). */ readonly maxDelayMs: number; /** Symmetric jitter fraction in [0,1]; 0 disables perturbation (RETRY-10). */ readonly jitter: number; diff --git a/packages/core/src/retry/retry-step.ts b/packages/core/src/retry/retry-step.ts index bfc7e1f..09b5534 100644 --- a/packages/core/src/retry/retry-step.ts +++ b/packages/core/src/retry/retry-step.ts @@ -16,12 +16,36 @@ export const RETRY_STEP_TYPE: unique symbol = Symbol('dexpace.retry'); * reach `RetryConfig`, and a no-argument `retryStep()` must stay the default-tuned pillar step * (RETRY-12). * - * @internal + * @public */ export interface RetryStepOptions { + /** + * Policy overrides. Any omitted field takes its spec default (RETRY-12). + * + * @defaultValue the spec defaults `retrySettings()` supplies + */ readonly settings?: Partial | undefined; + /** + * The wall-clock and sleep seam, injected so backoff is testable without real time. The same + * instance also satisfies `AuthStepSettings.clock`, so one `Clock` drives every pillar in a + * pipeline (CFG-15..CFG-18). + * + * @defaultValue a `Clock` over `Date.now`, `performance.now`, and a cancellable `setTimeout` + */ readonly clock?: Clock | undefined; + /** + * The randomness seam jitter draws from, in `[0, 1)`. Injected so a jittered schedule is + * assertable (RETRY-10). + * + * @defaultValue `Math.random` + */ readonly random?: (() => number) | undefined; + /** + * RETRY-39's caller override: returns the delay in milliseconds to use for `attempt`, or + * `undefined` to fall through to the configured schedule for that attempt. + * + * @defaultValue absent, so every attempt uses the configured schedule + */ readonly delayOverride?: ((attempt: number) => number | undefined) | undefined; } @@ -87,7 +111,7 @@ function configFrom( * @param options - settings overrides and the injected clock, randomness, and delay override. * @returns the descriptor to install in a pipeline's RETRY slot. * - * @internal + * @public */ export function retryStep(options: RetryStepOptions = {}): StepDescriptor { // Built ONCE per installed step, not per request: `retrySettings()` validates every field and diff --git a/packages/core/src/retry/settings.ts b/packages/core/src/retry/settings.ts index 3a69ca3..a16ee3f 100644 --- a/packages/core/src/retry/settings.ts +++ b/packages/core/src/retry/settings.ts @@ -12,7 +12,7 @@ import {RETRYABLE_STATUSES} from './classify.js'; * Immutable and stateless after construction, so one instance is safe for concurrent invocation * (RETRY-42/RECOV-28). * - * @internal + * @public */ export interface RetrySettings extends BackoffSettings { /** Total wire sends including the initial one; 1 disables retries (RETRY-14, RECOV-34). */ diff --git a/scripts/verify-consumer-types.mjs b/scripts/verify-consumer-types.mjs index a6f17ed..fdb6cb8 100644 --- a/scripts/verify-consumer-types.mjs +++ b/scripts/verify-consumer-types.mjs @@ -56,14 +56,73 @@ const workDir = mkdtempSync(join(tmpdir(), 'dexpace-consumer-types-')); // Exercises the surface most likely to reference a declaration the consumer's lib cannot resolve: // the resource-owning class, an async iterable/stream type, a generic, and a factory. +// +// It ALSO names every type the pillar-authoring surface promoted in Phase 5c, because a second defect +// got through every other gate too: an `@internal` token inside a prose comment above the barrel's +// context-family export made `stripInternal` delete that export from the emitted `.d.ts`. `typecheck` +// passed (the source says it is exported), `build` passed (tsc emitted happily), and `api:ci` passed +// because api-extractor recorded the resulting `ae-forgotten-export` as report TEXT. Nothing compiled +// the promoted names from outside the package, so nothing noticed. Naming them here is what makes +// that class of silent elision loud. const consumer = ` import { + ApiKeyCredential, + type ApiKeyCredentialConfig, + AuthResolutionError, + type AuthCredentialSet, + type AuthDescriptor, + type AuthRequirement, + type AuthScheme, + type AuthStepSettings, + type AuthTiers, + authRequirementsEqual, + authStep, + type BackoffSettings, + type BasicCredential, + type BearerCredential, + BearerToken, + bearerTokensEqual, type Body, byteArrayBody, + type ChallengeHook, + type Clock, + createAuthDescriptor, + createAuthRequirement, + createBearerToken, + type DigestAlgorithm, + type DigestCredential, + type DispatchContext, + type ExchangeContext, + type ExecutionContext, + type InstrumentationBundle, materialize, + NameKeyCredential, + type Next, + PILLAR_STAGES, + PipelineBuilder, + PlaintextCredentialError, + type RedirectCondition, + type RedirectPredicate, + type RedirectSettings, + redirectStep, + Request, + type RequestContext, Response, + type RetrySettings, + type RetryStepOptions, + retryStep, + Runtime, + type Stage, + STAGE_ORDER, + type StandardResilienceOptions, + standardResilience, Status, + type Step, + type StepContext, + type StepDescriptor, toHttpError, + type TokenProvider, + type Transport, TypedResponse, } from ${JSON.stringify(built)}; @@ -85,6 +144,110 @@ export function typed(wrapper: TypedResponse): Promise { export const bytes: Body = byteArrayBody(new Uint8Array([1]), 'application/octet-stream'); export const errorOf = toHttpError; export const ok: number = Status.of(200).code; + +// --- the pillar-authoring surface promoted in Phase 5c --- +export function kindOf(context: ExecutionContext): string { + return context.kind; +} +export function dispatchKey(context: DispatchContext): symbol { + return context.key; +} +export function requestOf(context: RequestContext): Request { + return context.request; +} +export function responseOf(context: ExchangeContext): Response { + return context.response; +} +export function traceOf(bundle: InstrumentationBundle): string { + return bundle.traceId; +} +export const customStep: Step = async (request, ctx: StepContext) => { + const advance: Next = ctx.fork?.() ?? ctx.next; + kindOf(ctx.context); + return advance(request); +}; +export const descriptor: StepDescriptor = { + type: Symbol('consumer.custom'), + stage: 'PRE_AUTH' satisfies Stage, + fn: customStep, +}; +export const stageCount: number = STAGE_ORDER.length + PILLAR_STAGES.size; + +export function assemble(transport: Transport): Runtime { + const provider: TokenProvider = async () => createBearerToken('t', Date.now() + 60_000); + const settings: AuthStepSettings = { + credentials: { + apiKey: {credential: new ApiKeyCredential('k'), prefix: 'ApiKey'}, + basic: {username: 'u', password: 'p'}, + digest: {username: 'u', password: 'p', algorithmPreference: ['SHA-256' satisfies DigestAlgorithm]}, + bearer: {provider, marginMs: 5_000}, + }, + tiers: { + client: createAuthDescriptor([ + createAuthRequirement('OAUTH2' satisfies AuthScheme, ['scope.read']), + createAuthRequirement('NO_AUTH'), + ]), + } satisfies AuthTiers, + challengeHook: (async () => undefined) satisfies ChallengeHook, + bearerMarginMs: 30_000, + clock: {now: () => Date.now()}, + }; + const options: StandardResilienceOptions = { + auth: settings, + retry: {settings: {maxAttempts: 3}} satisfies RetryStepOptions, + redirect: {maxHops: 2}, + }; + const hand = new PipelineBuilder(transport) + .append(redirectStep({maxHops: 2})) + .append(retryStep()) + .append(authStep(settings)) + .append(descriptor) + .build(); + const seeded = PipelineBuilder.seedFrom(hand, 'nest').build(); + return standardResilience(seeded, options); +} + +export function requirementEquality(a: AuthRequirement, b: AuthRequirement): boolean { + return authRequirementsEqual(a, b); +} +export function tokenEquality(a: BearerToken, b: BearerToken): boolean { + return bearerTokensEqual(a, b); +} +export function describeDescriptor(d: AuthDescriptor): boolean { + return d.allowsAnonymous; +} +export function nameKey(): NameKeyCredential { + return new NameKeyCredential('x-api-key', 'k'); +} +export function narrow(error: unknown): string | undefined { + if (error instanceof PlaintextCredentialError) return error.scheme; + if (error instanceof AuthResolutionError) return error.requiredSchemes?.[0]; + return undefined; +} +export function credentialSet(set: AuthCredentialSet): BasicCredential | undefined { + return set.basic; +} +export function bearerCredential(c: BearerCredential): TokenProvider { + return c.provider; +} +export function digestCredential(c: DigestCredential): string { + return c.username; +} +export function apiKeyConfig(c: ApiKeyCredentialConfig): string | undefined { + return c.headerName; +} +export function redirectPolicy(s: RedirectSettings, p: RedirectPredicate): boolean { + return p({response: undefined as unknown as Response, redirectsFollowed: s.maxHops, visited: new Set()}); +} +export function retryPolicy(s: RetrySettings, b: BackoffSettings): number { + return s.maxAttempts + b.initialDelayMs; +} +export function clockNow(c: Clock): number { + return c.now(); +} +export function conditionOf(c: RedirectCondition): number { + return c.redirectsFollowed; +} `; const tsconfig = { @@ -117,10 +280,12 @@ try { } catch (error) { const detail = `${error.stdout ?? ''}${error.stderr ?? ''}`.trim(); console.error( - 'consumer-types check FAILED: the published .d.ts does not compile against this workspace\n' + + "consumer-types check FAILED: the published .d.ts does not compile against this workspace's\n" + `own declared lib (${lib.join(', ')}) with types: [].\n\n${detail}\n\n` + - 'A declaration is reaching for a global that only a devDependency supplies. Either drop it, or\n' + - 'add the lib entry to tsconfig.base.json and raise engines.node to a runtime that has it.', + 'Either a declaration is reaching for a global that only a devDependency supplies (drop it, or\n' + + 'add the lib entry to tsconfig.base.json and raise engines.node to a runtime that has it), or\n' + + 'the barrel claims an export the emitted .d.ts does not actually carry -- check for an\n' + + '`@internal` token inside a comment above the export, which `stripInternal` deletes it for.', ); process.exit(1); } finally { @@ -128,5 +293,6 @@ try { } console.log( - `consumer-types check passed: dist/*.d.ts compiles on lib [${lib.join(', ')}] with types: []`, + `consumer-types check passed: dist/*.d.ts compiles on lib [${lib.join(', ')}] with types: [],\n` + + 'including every symbol the pillar-authoring surface promotes.', ); diff --git a/test/node-conformance/auth.test.mjs b/test/node-conformance/auth.test.mjs new file mode 100644 index 0000000..58dd39e --- /dev/null +++ b/test/node-conformance/auth.test.mjs @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +// test/node-conformance/auth.test.mjs +// +// Phase 5c reaches three runtime-provided globals that Bun implements independently of Node, and every +// one of them fails SILENTLY rather than loudly if the two disagree: +// +// 1. `globalThis.crypto.subtle.digest('SHA-256', ...)` -- the SHA-256/SHA-256-sess Digest algorithms +// (AUTH-15/AUTH-17). A wrong digest is still a well-formed hex string, so a divergence produces a +// header the server rejects rather than an exception a test would catch. The RFC 7616 vectors here +// are the only thing that pins it. `bun test` covers the same vectors on Bun's implementation; this +// file covers Node's. +// 2. `globalThis.crypto.getRandomValues()` -- the >=128-bit client nonce (AUTH-20). Its absence from +// ESM on every Node 18 release is one of the two reasons `engines.node` reads `>=20.3`, so this is +// also the floor assertion for that global. +// 3. `globalThis.btoa` -- Basic stamping (AUTH-14). A Latin-1/UTF-8 mismatch on a non-ASCII password +// produces a valid-looking base64 blob that authenticates against nothing. +// +// A fourth surface is structural rather than platform-specific but is only observable through Web +// Streams: AUTH-30/AUTH-31/AUTH-32's response-lifecycle discipline is observed through +// `countingResponse()`'s `cancel()`/`pull()` hooks, and Node's timing there is an independent +// implementation of Bun's. +// +// A fifth was added at 5c's adversarial review: AUTH-34's single-flight fetch is shared, so it carries +// no caller signal and each caller instead races its own wait against its own `AbortSignal`. That rests +// on `AbortController`/`AbortSignal` listener add-and-remove semantics and on `Promise.race` settling +// order, both of which Bun implements independently of Node. A divergence here does not throw -- it +// either hangs a caller that aborted or rejects one that did not. +// +// The listener ACCOUNTING that shape depends on is asserted here rather than in `bearer-cache.test.ts` +// for two reasons: `node:events`' `getEventListeners` is the only portable way to count listeners on +// an `AbortSignal` without spying on `removeEventListener`, and no colocated unit test under +// `packages/core/src/` imports a `node:` builtin -- that is the portability posture `basic.ts` and +// `digest.ts` keep by reaching for Web Crypto and `btoa` instead. The Bun side asserts the leak's +// behavioural consequence instead. +// +// `auth/` is `@internal` apart from the barrel-promoted configuration surface, so the handler internals +// are reached by direct `dist/` file path, per this suite's import rule. +import assert from 'node:assert/strict'; +import {getEventListeners} from 'node:events'; +import {describe, it} from 'node:test'; +import { + Request, + authStep, + createAuthDescriptor, + createAuthRequirement, +} from '@dexpace/core'; +import {basicHandler} from '../../packages/core/dist/auth/basic.js'; +import {BearerTokenCache} from '../../packages/core/dist/auth/bearer-cache.js'; +import {createBearerToken} from '../../packages/core/dist/auth/credential.js'; +import { + computeDigestResponse, + digestHandler, +} from '../../packages/core/dist/auth/digest.js'; +import {md5, toHex} from '../../packages/core/dist/auth/md5.js'; +import {createRequestContext} from '../../packages/core/dist/context/context.js'; +import {Cursor} from '../../packages/core/dist/pipeline/cursor.js'; +import { + FakeTransport, + countingResponse, +} from '../../packages/core/dist/testing/fake-transport.js'; + +const REALM = 'testrealm@host.com'; +const NONCE = 'dcd98b7102dd2f0e8b11d0f600bfb0c093'; +const VECTOR = { + realm: REALM, + nonce: NONCE, + isUtf8: true, + method: 'GET', + uri: '/dir/index.html', + username: 'Mufasa', + password: 'Circle Of Life', + cnonce: '0a4f113b', + nc: '00000001', +}; + +function digestChallenge(params) { + return {scheme: 'digest', params: new Map(Object.entries(params))}; +} + +function aRequest(url = 'https://example.com/a') { + return Request.newBuilder().url(url).build(); +} + +function runThrough(descriptor, transport, request = aRequest()) { + return new Cursor({ + steps: [descriptor], + transport, + request, + context: createRequestContext(request), + }).advance(); +} + +function challengeResponse(status, headerName, headerValue) { + const base = countingResponse(status); + const response = base.response + .newBuilder() + .headers( + base.response.headers + .newBuilder() + .setInbound(headerName, headerValue) + .build(), + ) + .build(); + return {response, cancelCount: base.cancelCount}; +} + +describe('Web Crypto SHA-256 under Node (AUTH-15/AUTH-17)', () => { + it('computes the RFC 7616 SHA-256 response, qop=auth', async () => { + assert.equal( + await computeDigestResponse({ + ...VECTOR, + algorithm: 'SHA-256', + hasQopAuth: true, + }), + '5abdd07184ba512a22c53f41470e5eea7dcaa3a93a59b630c13dfe0a5dc6e38b', + ); + }); + + it('computes the RFC 7616 SHA-256-sess response, qop=auth', async () => { + assert.equal( + await computeDigestResponse({ + ...VECTOR, + algorithm: 'SHA-256-sess', + hasQopAuth: true, + }), + 'b8822e12417cb7750f4e2b8515f0dcf25b7dd26993e80bee1426201446a7f59b', + ); + }); + + it('computes the RFC 7616 MD5 response, qop=auth, through the hand-rolled digest', async () => { + assert.equal( + await computeDigestResponse({ + ...VECTOR, + algorithm: 'MD5', + hasQopAuth: true, + }), + '6629fae49393a05397450978507c4ef1', + ); + }); + + it('pins the hand-rolled MD5 primitive itself against the RFC 1321 "abc" vector', async () => { + assert.equal( + toHex(md5(new TextEncoder().encode('abc'))), + '900150983cd24fb0d6963f7d28e17f72', + ); + }); + + it('hashes UTF-8 and ISO-8859-1 inputs differently for a non-ASCII password (AUTH-21)', async () => { + const utf8 = await computeDigestResponse({ + ...VECTOR, + password: 'pässwörd', + algorithm: 'SHA-256', + hasQopAuth: true, + isUtf8: true, + }); + const latin1 = await computeDigestResponse({ + ...VECTOR, + password: 'pässwörd', + algorithm: 'SHA-256', + hasQopAuth: true, + isUtf8: false, + }); + assert.notEqual(utf8, latin1); + }); +}); + +describe('crypto.getRandomValues under Node (AUTH-20)', () => { + it('draws a fresh 128-bit client nonce per stamp', async () => { + const handler = digestHandler('u', 'p'); + const challenge = digestChallenge({ + realm: REALM, + nonce: NONCE, + qop: 'auth', + }); + const request = {method: 'GET', requestTarget: '/x'}; + + const first = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, request), + ); + const second = /cnonce="([0-9a-f]+)"/u.exec( + await handler.stamp(challenge, request), + ); + + assert.equal(first[1].length, 32); // 16 bytes as hex + assert.notEqual(first[1], second[1]); + }); +}); + +describe('globalThis.btoa under Node (AUTH-14)', () => { + it('base64-encodes the UTF-8 bytes of an ASCII credential', async () => { + const value = await basicHandler('Aladdin', 'open sesame').stamp({ + scheme: 'basic', + params: new Map(), + }); + assert.equal(value, 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=='); + }); + + it('base64-encodes the UTF-8 bytes -- not the Latin-1 code units -- of a non-ASCII credential', async () => { + const value = await basicHandler('üser', 'päss').stamp({ + scheme: 'basic', + params: new Map(), + }); + const utf8 = new TextEncoder().encode('üser:päss'); + assert.equal(value, `Basic ${btoa(String.fromCharCode(...utf8))}`); + // A naive `btoa('üser:päss')` would produce a different, shorter string on any runtime that + // accepted it at all -- this is the assertion that catches an encoder swap. + assert.notEqual( + value, + `Basic ${Buffer.from('üser:päss', 'latin1').toString('base64')}`, + ); + }); +}); + +describe('challenge response lifecycle over Node Web Streams (AUTH-30/AUTH-31/AUTH-32)', () => { + const tiers = { + client: createAuthDescriptor([createAuthRequirement('BASIC')]), + }; + const credentials = {basic: {username: 'u', password: 'p'}}; + + it('closes the original 401 before re-driving, and leaves the replacement response open', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const success = countingResponse(200); + const transport = new FakeTransport([ + challenged.response, + success.response, + ]); + + const response = await runThrough( + authStep({credentials, tiers}), + transport, + ); + + assert.equal(transport.sendCount, 2); + assert.equal(challenged.cancelCount(), 1); + assert.equal(success.cancelCount(), 0); + assert.equal(response, success.response); + }); + + it('closes the 401 before propagating a throwing challenge hook (AUTH-32)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Basic realm="x"', + ); + const transport = new FakeTransport([challenged.response]); + const descriptor = authStep({ + credentials: {}, + tiers: {client: createAuthDescriptor([createAuthRequirement('NO_AUTH')])}, + challengeHook: () => Promise.reject(new Error('hook exploded')), + }); + + await assert.rejects(runThrough(descriptor, transport), /hook exploded/u); + assert.equal(challenged.cancelCount(), 1); + }); + + it('leaves an unanswerable 401 open and unclosed -- the caller owns it (AUTH-33)', async () => { + const challenged = challengeResponse( + 401, + 'WWW-Authenticate', + 'Negotiate abc123', + ); + const transport = new FakeTransport([ + challenged.response, + countingResponse(200).response, + ]); + + const response = await runThrough( + authStep({credentials, tiers}), + transport, + ); + + assert.equal(transport.sendCount, 1); + assert.equal(challenged.cancelCount(), 0); + assert.equal(response, challenged.response); + }); +}); + +describe('single-flight cancellation over Node AbortSignal (AUTH-34)', () => { + it('leaves no abort listener behind on a signal reused across many token fetches', async () => { + // `raceAbort` adds one `abort` listener per WAIT and removes it in a `finally`. Drop that + // removal and nothing in the suite fails, but a caller signal outliving many fetches -- one + // request driving a long paginated sweep -- accumulates a dead listener per fetch until Node's + // MaxListenersExceededWarning fires. The signal is never aborted here, so `{once: true}` cannot + // do the cleanup for us: only the explicit removal can. + const cache = new BearerTokenCache(); + const controller = new AbortController(); + + for (let round = 0; round < 12; round += 1) { + const token = createBearerToken(`t${round}`, 10_000); + await cache.stamp({ + provider: () => Promise.resolve(token), + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + cache.evict(`Bearer t${round}`); // send the next round back down the fetch path + } + + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + }); + + it("an aborting caller stops waiting without cancelling a coalesced caller's fetch", async () => { + let release; + let invocations = 0; + const provider = () => { + invocations += 1; + return new Promise(resolve => { + release = resolve; + }); + }; + const cache = new BearerTokenCache(); + const controller = new AbortController(); + + const aborting = cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: controller.signal, + }); + const patient = cache.stamp({ + provider, + marginMs: 0, + nowMs: 0, + signal: undefined, + }); + assert.equal(invocations, 1); + + controller.abort(new Error('caller A gave up')); + await assert.rejects(aborting, /caller A gave up/u); + + release(createBearerToken('t1', 10_000)); + assert.equal((await patient).token, 't1'); + assert.equal(invocations, 1); + }); +});