fix: enforce installed HTTP auth profiles

Install the REST auth profile registry once at composition and make it the
single transport authority for V3. Contract composition now rejects an
unregistered authProfileId, so the executor never resolves a profile at
runtime.

The credential collaborator contributes proof headers only: Fetch credentials
come from the resolved profile, transport-owned and forbidden headers are
rejected, headers outside the profile's allowed set are rejected, and a missing
required header fails closed as AUTH_INTEGRATION_FAILURE with zero fetch calls.
The final invariant re-proves credentials mode and the exact header sets.

Demo mode satisfies the strict bearer profile with a fixed non-secret marker
instead of weakening REFERENCE_EXTERNAL_BEARER. Credential owners now receive
the operation lifetime through AuthOperationContext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 22:56:44 +09:00
co-authored by Claude Opus 5
parent 67cc5b6d2c
commit 4e87bacdf3
21 changed files with 5045 additions and 41 deletions
+90 -7
View File
@@ -17,10 +17,16 @@ import {
readBoundedBytes,
} from "./bounded-body-reader.ts";
import {
admitCredentialHeaders,
checkFinalInvariants,
projectRequest,
type CredentialAdmissionFailure,
type CredentialPatchOutcome,
} from "./http-contract-bridge.ts";
import {
INSTALLED_REST_AUTH_PROFILES,
type InstalledRestAuthProfiles,
} from "../../contracts/rest-profiles.ts";
import {
certaintyForAbandonedAttempt,
classifyProblemEffect,
@@ -124,8 +130,31 @@ export type HttpExecutionOutcome<Value, Problem> =
| Readonly<{
kind: "CANCELLED";
effect: "NOT_STARTED" | "MAYBE_APPLIED";
}>
| Readonly<{
kind: "AUTH_INTEGRATION_FAILURE";
reason: AuthIntegrationFailureReason;
effect: "NOT_APPLICABLE" | "NOT_STARTED";
}>;
/**
* §7.7 / VD-23. A configuration or collaborator contract breach, never a user
* session state. `UNAUTHENTICATED` stays reserved for the latter.
*/
export type AuthIntegrationFailureReason =
| "UNKNOWN_AUTH_PROFILE"
| CredentialAdmissionFailure;
/**
* §8.5. Credential collaborators receive the operation lifetime so a
* cooperative owner can abandon its own work; a non-cooperative one is still
* bounded by the executor's race against the same signal.
*/
export type AuthOperationContext = Readonly<{
signal: AbortSignal;
deadlineAtMonotonicMs: number;
}>;
export type CancellationOwner =
| "CALLER"
| "ROUTE_TRANSITION"
@@ -206,6 +235,8 @@ function observationErrorKind(
: outcome.failure.kind;
case "CANCELLED":
return "REQUEST_ABORTED";
case "AUTH_INTEGRATION_FAILURE":
return outcome.reason;
}
}
@@ -221,12 +252,15 @@ export type ContractHttpExecutorDependencies = Readonly<{
baseUrl: string;
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
maxRetryAttempts: number;
/** The installed profile registry; the executor never invents a profile. */
authProfiles?: InstalledRestAuthProfiles;
attachCredentials(
operation: Readonly<{
operationId: string;
authProfileId: string;
method: string;
}>,
context: AuthOperationContext,
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
fetcher?: typeof fetch;
/** Adapter seam for the common bounded response reader. */
@@ -341,6 +375,8 @@ export function createContractHttpExecutor(
dependencies: ContractHttpExecutorDependencies,
): ContractHttpExecutor {
const fetcher = dependencies.fetcher ?? fetch;
const authProfiles =
dependencies.authProfiles ?? INSTALLED_REST_AUTH_PROFILES;
const readResponseBytes =
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
const now = dependencies.monotonicNow ?? (() => performance.now());
@@ -447,6 +483,16 @@ export function createContractHttpExecutor(
try {
// §7.7. The installed registry is the only source of a profile. Composition
// already rejects unknown identities; this is the runtime fail-close.
const authProfile = authProfiles.get(policy.authProfileId);
if (!authProfile) {
return finish(
authIntegrationFailure("UNKNOWN_AUTH_PROFILE", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
// §7.4 step 1-2: capture the scope and verify it is still current.
if (!context.scope.isCurrent()) {
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
@@ -503,11 +549,17 @@ export function createContractHttpExecutor(
let patchOperation: Promise<CredentialPatchOutcome>;
try {
patchOperation = Promise.resolve(
dependencies.attachCredentials({
operationId: contract.operationId,
authProfileId: policy.authProfileId,
method: contract.method,
}),
dependencies.attachCredentials(
{
operationId: contract.operationId,
authProfileId: policy.authProfileId,
method: contract.method,
},
Object.freeze({
signal: lifetimeController.signal,
deadlineAtMonotonicMs: deadlineAt,
}),
),
);
} catch {
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
@@ -548,6 +600,9 @@ export function createContractHttpExecutor(
// A missing credential never downgrades into an anonymous request.
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
}
// The idempotency key is contract-owned, so a credential owner supplying it
// stays the more specific request-contract violation.
if (
Object.keys(patch.headers).some(
(name) => name.toLowerCase() === "idempotency-key",
@@ -559,9 +614,21 @@ export function createContractHttpExecutor(
);
}
// §7.7. The profile, not the patch, decides what may travel. Rejection here
// means zero fetch calls.
const admission = admitCredentialHeaders(patch.headers, authProfile);
if (!admission.ok) {
return finish(
authIntegrationFailure(admission.failure, isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
// Transport-owned headers are written last so no credential entry can
// shadow Accept or Content-Type through key ordering.
const headers: Record<string, string> = {
...admission.headers,
Accept: "application/json",
...patch.headers,
};
if (contract.requestBody === "JSON") {
headers["Content-Type"] = "application/json";
@@ -619,7 +686,7 @@ export function createContractHttpExecutor(
headers,
redirect: "error",
referrerPolicy: "no-referrer",
credentials: patch.credentials,
credentials: authProfile.credentials,
cache: "no-store",
signal: controller.signal,
...(projected.request.bodyBytes
@@ -636,6 +703,7 @@ export function createContractHttpExecutor(
requestByteLimit: policy.requestByteLimit,
deadlineRemainingMs: remaining(),
scopeIsCurrent: context.scope.isCurrent(),
authProfile,
});
if (invariantFailure) {
clearTimeout(deadlineTimer);
@@ -1232,6 +1300,21 @@ function scopeFenced<Value, Problem>(
return violation("SCOPE_FENCED", "RESPONSE", effect);
}
/**
* §7.7. A credential collaborator or profile-binding breach. It always resolves
* before dispatch, so the command effect is `NOT_STARTED` and fetch count zero.
*/
function authIntegrationFailure<Value, Problem>(
reason: AuthIntegrationFailureReason,
isCommand: boolean,
): HttpExecutionOutcome<Value, Problem> {
return Object.freeze({
kind: "AUTH_INTEGRATION_FAILURE" as const,
reason,
effect: isCommand ? ("NOT_STARTED" as const) : ("NOT_APPLICABLE" as const),
});
}
function preDispatchEffect(isCommand: boolean): HttpEffectCertainty {
return isCommand ? "NOT_STARTED" : "NOT_APPLICABLE";
}