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:
co-authored by
Claude Opus 5
parent
67cc5b6d2c
commit
4e87bacdf3
@@ -2,6 +2,11 @@ import {
|
||||
HTTP_EXECUTION_CEILINGS,
|
||||
type InstalledHttpContract,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
import {
|
||||
CREDENTIAL_HEADER_NAMES,
|
||||
type CredentialHeaderName,
|
||||
type RestAuthProfile,
|
||||
} from "../../contracts/rest-profiles.ts";
|
||||
|
||||
/**
|
||||
* §7.4–§7.7. Descriptor-driven request projection.
|
||||
@@ -11,21 +16,99 @@ import {
|
||||
* bounds and re-verifies them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* §7.7 / VD-23. A credential owner contributes proof headers only. Fetch
|
||||
* `credentials` belongs to the installed auth profile, so it is deliberately
|
||||
* absent from this outcome.
|
||||
*/
|
||||
export type CredentialPatchOutcome =
|
||||
| Readonly<{
|
||||
kind: "READY";
|
||||
headers: Readonly<Record<string, string>>;
|
||||
credentials: RequestCredentials;
|
||||
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
|
||||
}>
|
||||
| Readonly<{ kind: "UNAUTHENTICATED" }>
|
||||
| Readonly<{ kind: "UNAVAILABLE" }>
|
||||
| Readonly<{ kind: "SCOPE_FENCED" }>;
|
||||
|
||||
/** §7.7. The complete set of headers a credential bridge may contribute. */
|
||||
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
"x-tenant-context",
|
||||
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set(
|
||||
CREDENTIAL_HEADER_NAMES,
|
||||
);
|
||||
|
||||
export type CredentialAdmissionFailure =
|
||||
| "TRANSPORT_OWNED_HEADER"
|
||||
| "CREDENTIAL_HEADER_NOT_ALLOWED"
|
||||
| "CREDENTIAL_HEADER_VALUE_INVALID"
|
||||
| "MISSING_REQUIRED_CREDENTIAL_HEADER";
|
||||
|
||||
export type CredentialAdmissionOutcome =
|
||||
| Readonly<{
|
||||
ok: true;
|
||||
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
|
||||
}>
|
||||
| Readonly<{ ok: false; failure: CredentialAdmissionFailure }>;
|
||||
|
||||
const MAX_CREDENTIAL_HEADER_VALUE_BYTES = 8_192;
|
||||
|
||||
/**
|
||||
* §7.7. Admits a credential patch against the resolved profile before any
|
||||
* header object is built. A rejection here guarantees `fetch()` is not called:
|
||||
* a credential owner cannot widen the profile, replace a transport-owned
|
||||
* header, or turn an authenticated profile into an anonymous request.
|
||||
*/
|
||||
export function admitCredentialHeaders(
|
||||
patchHeaders: Readonly<Record<string, unknown>>,
|
||||
profile: Readonly<{
|
||||
allowedCredentialHeaders: readonly CredentialHeaderName[];
|
||||
requiredCredentialHeaders: readonly CredentialHeaderName[];
|
||||
}>,
|
||||
): CredentialAdmissionOutcome {
|
||||
const admitted: Partial<Record<CredentialHeaderName, string>> = {};
|
||||
const seen = new Set<CredentialHeaderName>();
|
||||
for (const [name, value] of Object.entries(patchHeaders)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (TRANSPORT_OWNED_HEADERS.has(lower) || FORBIDDEN_REQUEST_HEADERS.has(lower)) {
|
||||
return frozenAdmissionFailure("TRANSPORT_OWNED_HEADER");
|
||||
}
|
||||
if (
|
||||
!ALLOWED_CREDENTIAL_HEADERS.has(lower) ||
|
||||
!profile.allowedCredentialHeaders.includes(lower as CredentialHeaderName)
|
||||
) {
|
||||
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
|
||||
}
|
||||
const credentialName = lower as CredentialHeaderName;
|
||||
if (seen.has(credentialName)) {
|
||||
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
|
||||
}
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
/[\r\n]/.test(value) ||
|
||||
encoder.encode(value).byteLength > MAX_CREDENTIAL_HEADER_VALUE_BYTES
|
||||
) {
|
||||
return frozenAdmissionFailure("CREDENTIAL_HEADER_VALUE_INVALID");
|
||||
}
|
||||
seen.add(credentialName);
|
||||
admitted[credentialName] = value;
|
||||
}
|
||||
for (const required of profile.requiredCredentialHeaders) {
|
||||
if (!seen.has(required)) {
|
||||
return frozenAdmissionFailure("MISSING_REQUIRED_CREDENTIAL_HEADER");
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ok: true as const, headers: Object.freeze(admitted) });
|
||||
}
|
||||
|
||||
function frozenAdmissionFailure(
|
||||
failureKind: CredentialAdmissionFailure,
|
||||
): CredentialAdmissionOutcome {
|
||||
return Object.freeze({ ok: false as const, failure: failureKind });
|
||||
}
|
||||
|
||||
const TRANSPORT_OWNED_HEADERS: ReadonlySet<string> = new Set([
|
||||
"accept",
|
||||
"content-type",
|
||||
"idempotency-key",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
|
||||
@@ -217,6 +300,8 @@ export type FinalInvariantInput = Readonly<{
|
||||
requestByteLimit: number;
|
||||
deadlineRemainingMs: number;
|
||||
scopeIsCurrent: boolean;
|
||||
/** The resolved installed profile this dispatch must match exactly. */
|
||||
authProfile: RestAuthProfile;
|
||||
}>;
|
||||
|
||||
export type FinalInvariantFailure =
|
||||
@@ -224,7 +309,10 @@ export type FinalInvariantFailure =
|
||||
| "URL_NOT_ALLOWED"
|
||||
| "REDIRECT_MODE_INVALID"
|
||||
| "CREDENTIALS_MODE_INVALID"
|
||||
| "CREDENTIALS_MODE_MISMATCH"
|
||||
| "HEADER_NOT_ALLOWED"
|
||||
| "CREDENTIAL_HEADER_NOT_ALLOWED"
|
||||
| "MISSING_REQUIRED_CREDENTIAL_HEADER"
|
||||
| "FORBIDDEN_HEADER"
|
||||
| "REQUEST_BODY_TOO_LARGE"
|
||||
| "DEADLINE_EXPIRED"
|
||||
@@ -258,17 +346,30 @@ export function checkFinalInvariants(
|
||||
) {
|
||||
return "CREDENTIALS_MODE_INVALID";
|
||||
}
|
||||
// The profile is the transport authority: a credential collaborator cannot
|
||||
// move the request onto a different Fetch credentials mode.
|
||||
if (input.init.credentials !== input.authProfile.credentials) {
|
||||
return "CREDENTIALS_MODE_MISMATCH";
|
||||
}
|
||||
|
||||
const presentCredentialHeaders = new Set<string>();
|
||||
for (const name of Object.keys(input.headers)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
|
||||
if (TRANSPORT_OWNED_HEADERS.has(lower)) continue;
|
||||
if (!ALLOWED_CREDENTIAL_HEADERS.has(lower)) return "HEADER_NOT_ALLOWED";
|
||||
if (
|
||||
lower !== "accept" &&
|
||||
lower !== "content-type" &&
|
||||
lower !== "idempotency-key" &&
|
||||
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
|
||||
!input.authProfile.allowedCredentialHeaders.includes(
|
||||
lower as CredentialHeaderName,
|
||||
)
|
||||
) {
|
||||
return "HEADER_NOT_ALLOWED";
|
||||
return "CREDENTIAL_HEADER_NOT_ALLOWED";
|
||||
}
|
||||
presentCredentialHeaders.add(lower);
|
||||
}
|
||||
for (const required of input.authProfile.requiredCredentialHeaders) {
|
||||
if (!presentCredentialHeaders.has(required)) {
|
||||
return "MISSING_REQUIRED_CREDENTIAL_HEADER";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user