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
@@ -1,24 +1,26 @@
|
||||
import type {
|
||||
AuthSessionPort,
|
||||
CredentialOperationContext,
|
||||
CredentialPatch,
|
||||
CredentialRequestBinding,
|
||||
SessionState,
|
||||
} from "../../application/ports/auth-session-port.ts";
|
||||
import { CREDENTIAL_HEADER_NAMES } from "../../contracts/rest-profiles.ts";
|
||||
|
||||
export type ExternalSessionOwner = Readonly<{
|
||||
readState(): SessionState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
beginSignIn(returnTo?: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
attachCredential(binding: CredentialRequestBinding): Promise<CredentialPatch>;
|
||||
attachCredential(
|
||||
binding: CredentialRequestBinding,
|
||||
context?: CredentialOperationContext,
|
||||
): Promise<CredentialPatch>;
|
||||
recoverSession(): Promise<"restored" | "no-session">;
|
||||
notifyUnauthenticated(): void;
|
||||
}>;
|
||||
|
||||
const ALLOWED_CREDENTIAL_HEADERS = new Set([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
]);
|
||||
const ALLOWED_CREDENTIAL_HEADERS = new Set<string>(CREDENTIAL_HEADER_NAMES);
|
||||
const MAX_HEADER_VALUE_BYTES = 8_192;
|
||||
|
||||
export function validateCredentialPatch(value: unknown): CredentialPatch {
|
||||
@@ -54,8 +56,10 @@ export function createExternalAuthSessionAdapter(
|
||||
subscribe: (listener) => owner.subscribe(listener),
|
||||
beginSignIn: (returnTo) => owner.beginSignIn(returnTo),
|
||||
signOut: () => owner.signOut(),
|
||||
async credentialPatch(binding) {
|
||||
return validateCredentialPatch(await owner.attachCredential(binding));
|
||||
async credentialPatch(binding, context) {
|
||||
return validateCredentialPatch(
|
||||
await owner.attachCredential(binding, context),
|
||||
);
|
||||
},
|
||||
async recover() {
|
||||
const result = await owner.recoverSession();
|
||||
@@ -85,9 +89,23 @@ export function createAnonymousSessionAdapter(): AuthSessionPort {
|
||||
export type DemoSessionAdapter = AuthSessionPort &
|
||||
Readonly<{ setState(next: SessionState): void }>;
|
||||
|
||||
/**
|
||||
* §7.7. `AUTH_MODE=demo` still runs against the strict
|
||||
* `REFERENCE_EXTERNAL_BEARER` profile, so the demo owner must supply a real
|
||||
* proof header. This marker is a fixed, non-secret placeholder: it exists so
|
||||
* the demo path satisfies the bearer contract instead of weakening it.
|
||||
*/
|
||||
export const DEMO_AUTHORIZATION_MARKER = "Bearer demo-session-not-a-secret";
|
||||
|
||||
const DEMO_PATCH = Object.freeze({
|
||||
headers: Object.freeze({ authorization: DEMO_AUTHORIZATION_MARKER }),
|
||||
});
|
||||
|
||||
export function createDemoSessionAdapter(
|
||||
initialState: SessionState = "unauthenticated",
|
||||
demoPatch: CredentialPatch = DEMO_PATCH,
|
||||
): DemoSessionAdapter {
|
||||
const patch = validateCredentialPatch(demoPatch);
|
||||
let state = initialState;
|
||||
const listeners = new Set<() => void>();
|
||||
const setState = (next: SessionState) => {
|
||||
@@ -106,7 +124,7 @@ export function createDemoSessionAdapter(
|
||||
async signOut() {
|
||||
setState("unauthenticated");
|
||||
},
|
||||
credentialPatch: async () => EMPTY_PATCH,
|
||||
credentialPatch: async () => patch,
|
||||
async recover() {
|
||||
if (state === "recovery-pending") {
|
||||
setState("authenticated");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -22,8 +22,21 @@ export type CredentialPatch = Readonly<{
|
||||
headers: Readonly<Record<string, string>>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* §8.5. The transport lifetime handed to a credential owner. A cooperative
|
||||
* owner abandons its own work on abort; a non-cooperative one is still bounded
|
||||
* because the transport races the same signal.
|
||||
*/
|
||||
export type CredentialOperationContext = Readonly<{
|
||||
signal: AbortSignal;
|
||||
deadlineAtMonotonicMs: number;
|
||||
}>;
|
||||
|
||||
export type CredentialAttacher = Readonly<{
|
||||
credentialPatch(binding: CredentialRequestBinding): Promise<CredentialPatch>;
|
||||
credentialPatch(
|
||||
binding: CredentialRequestBinding,
|
||||
context?: CredentialOperationContext,
|
||||
): Promise<CredentialPatch>;
|
||||
onUnauthenticated(): void;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ import { createBrowserMutationIntentFactory } from "../adapters/platform/browser
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
||||
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
||||
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
|
||||
import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
|
||||
import {
|
||||
createRestProviderProfile,
|
||||
INSTALLED_REST_AUTH_PROFILES,
|
||||
} from "../contracts/rest-profiles.ts";
|
||||
import type { ClockPort } from "../application/ports/clock-port.ts";
|
||||
import type { MutationIntent } from "../contracts/mutation-intent.ts";
|
||||
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
|
||||
@@ -375,7 +378,10 @@ export async function createRuntimeAdapters(
|
||||
baseUrl: config.API_BASE_URL,
|
||||
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
||||
fetcher: context.fetcher,
|
||||
async attachCredentials(operation) {
|
||||
// §7.7. The installed registry owns Fetch credentials and the exact
|
||||
// credential-header sets; this collaborator only supplies proof headers.
|
||||
authProfiles: INSTALLED_REST_AUTH_PROFILES,
|
||||
async attachCredentials(operation, authContext) {
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
@@ -387,18 +393,20 @@ export async function createRuntimeAdapters(
|
||||
return Object.freeze({ kind: "UNAUTHENTICATED" as const });
|
||||
}
|
||||
try {
|
||||
const patch = await authSession.credentialPatch({
|
||||
origin: new URL(config.API_BASE_URL).origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
});
|
||||
const patch = await authSession.credentialPatch(
|
||||
{
|
||||
origin: new URL(config.API_BASE_URL).origin,
|
||||
method: operation.method,
|
||||
operationId: operation.operationId,
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers: patch.headers,
|
||||
credentials: "omit" as const,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* applies before a contribution may be composed.
|
||||
*/
|
||||
|
||||
import { INSTALLED_REST_AUTH_PROFILES } from "./rest-profiles.ts";
|
||||
|
||||
/** §7.3 hard ceilings. A contribution may lower these, never raise them. */
|
||||
export const HTTP_EXECUTION_CEILINGS = Object.freeze({
|
||||
defaultRequestBytes: 262_144,
|
||||
@@ -313,6 +315,11 @@ function assertExecutionPolicy(
|
||||
) {
|
||||
fail(`${label}: frontend execution policy identity`);
|
||||
}
|
||||
// §7.7 / VD-23. A declared profile that the installed registry does not own
|
||||
// is a composition failure; the executor must never resolve it at runtime.
|
||||
if (!INSTALLED_REST_AUTH_PROFILES.has(policy.authProfileId)) {
|
||||
fail(`${label}: unknown authProfileId ${policy.authProfileId}`);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(policy.requestByteLimit) ||
|
||||
policy.requestByteLimit < 0 ||
|
||||
|
||||
@@ -8,13 +8,34 @@ export type RestProviderProfile = Readonly<{
|
||||
referrerPolicy: "no-referrer";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* VD-23. The complete closed set of headers a credential owner may contribute.
|
||||
* Transport-owned headers (`accept`, `content-type`, `idempotency-key`) and
|
||||
* every forbidden request header are deliberately absent.
|
||||
*/
|
||||
export const CREDENTIAL_HEADER_NAMES = Object.freeze([
|
||||
"authorization",
|
||||
"x-csrf-token",
|
||||
"x-tenant-context",
|
||||
] as const);
|
||||
|
||||
export type CredentialHeaderName = (typeof CREDENTIAL_HEADER_NAMES)[number];
|
||||
|
||||
export type RestAuthProfile = Readonly<{
|
||||
authProfileId: string;
|
||||
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
|
||||
credentials: FetchCredentialsMode;
|
||||
allowedCredentialHeaders: readonly ("authorization" | "x-csrf-token")[];
|
||||
allowedCredentialHeaders: readonly CredentialHeaderName[];
|
||||
/**
|
||||
* Proof headers the transport must observe before dispatch. A missing entry
|
||||
* fails closed with zero `fetch()` calls rather than sending an anonymous
|
||||
* request under an authenticated profile.
|
||||
*/
|
||||
requiredCredentialHeaders: readonly CredentialHeaderName[];
|
||||
}>;
|
||||
|
||||
export type InstalledRestAuthProfiles = ReadonlyMap<string, RestAuthProfile>;
|
||||
|
||||
export type RestCsrfProfile = Readonly<{
|
||||
csrfProfileId: string;
|
||||
mode: "NONE" | "HEADER";
|
||||
@@ -27,15 +48,131 @@ export const REST_AUTH_PROFILES = Object.freeze({
|
||||
transport: "BEARER_HEADER",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: Object.freeze(["authorization"] as const),
|
||||
requiredCredentialHeaders: Object.freeze(["authorization"] as const),
|
||||
}),
|
||||
ANONYMOUS: Object.freeze({
|
||||
authProfileId: "ANONYMOUS",
|
||||
transport: "ANONYMOUS",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: Object.freeze([]),
|
||||
requiredCredentialHeaders: Object.freeze([]),
|
||||
}),
|
||||
} satisfies Readonly<Record<string, RestAuthProfile>>);
|
||||
|
||||
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function exactHeaderSet(
|
||||
names: unknown,
|
||||
label: string,
|
||||
): readonly CredentialHeaderName[] {
|
||||
if (!Array.isArray(names)) {
|
||||
throw new TypeError(`REST auth profile ${label} must be an array.`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const name of names) {
|
||||
if (!isCredentialHeaderName(name) || seen.has(name)) {
|
||||
throw new TypeError(`REST auth profile ${label} is not an exact set.`);
|
||||
}
|
||||
seen.add(name);
|
||||
}
|
||||
return Object.freeze([...(names as readonly CredentialHeaderName[])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* §7.7 / VD-23. Installs the composition-owned auth profile registry once.
|
||||
*
|
||||
* The registry — not a credential collaborator — owns Fetch `credentials` and
|
||||
* the exact allowed/required credential-header sets. An incoherent profile is a
|
||||
* composition failure, never a runtime downgrade.
|
||||
*/
|
||||
export function installRestAuthProfileRegistry(
|
||||
profiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
|
||||
): InstalledRestAuthProfiles {
|
||||
const installed = new Map<string, RestAuthProfile>();
|
||||
for (const [key, candidate] of Object.entries(profiles)) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
throw new TypeError(`REST auth profile ${key} is not an object.`);
|
||||
}
|
||||
const authProfileId = candidate.authProfileId;
|
||||
if (
|
||||
typeof authProfileId !== "string" ||
|
||||
authProfileId.length === 0 ||
|
||||
authProfileId !== key
|
||||
) {
|
||||
throw new TypeError(`REST auth profile ${key} has a mismatched identity.`);
|
||||
}
|
||||
const allowed = exactHeaderSet(
|
||||
candidate.allowedCredentialHeaders,
|
||||
"allowedCredentialHeaders",
|
||||
);
|
||||
const required = exactHeaderSet(
|
||||
candidate.requiredCredentialHeaders,
|
||||
"requiredCredentialHeaders",
|
||||
);
|
||||
if (!required.every((name) => allowed.includes(name))) {
|
||||
throw new TypeError(
|
||||
`REST auth profile ${key} requires a header it does not allow.`,
|
||||
);
|
||||
}
|
||||
const credentials = candidate.credentials;
|
||||
if (!["omit", "same-origin", "include"].includes(credentials)) {
|
||||
throw new TypeError(`REST auth profile ${key} has invalid credentials.`);
|
||||
}
|
||||
switch (candidate.transport) {
|
||||
case "ANONYMOUS":
|
||||
if (
|
||||
credentials !== "omit" ||
|
||||
allowed.length > 0 ||
|
||||
required.length > 0
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Anonymous REST auth profile ${key} cannot carry credentials.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "BEARER_HEADER":
|
||||
if (credentials !== "omit" || !required.includes("authorization")) {
|
||||
throw new TypeError(
|
||||
`Bearer REST auth profile ${key} must require authorization with omitted credentials.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "SAME_ORIGIN_COOKIE":
|
||||
if (credentials === "omit" || allowed.includes("authorization")) {
|
||||
throw new TypeError(
|
||||
`Cookie REST auth profile ${key} must send ambient credentials without a bearer header.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new TypeError(`REST auth profile ${key} has unknown transport.`);
|
||||
}
|
||||
installed.set(
|
||||
authProfileId,
|
||||
Object.freeze({
|
||||
authProfileId,
|
||||
transport: candidate.transport,
|
||||
credentials,
|
||||
allowedCredentialHeaders: allowed,
|
||||
requiredCredentialHeaders: required,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (installed.size === 0) {
|
||||
throw new TypeError("REST auth profile registry cannot be empty.");
|
||||
}
|
||||
return Object.freeze(new Map(installed)) as InstalledRestAuthProfiles;
|
||||
}
|
||||
|
||||
/** The single installed registry every composition root shares. */
|
||||
export const INSTALLED_REST_AUTH_PROFILES: InstalledRestAuthProfiles =
|
||||
installRestAuthProfileRegistry();
|
||||
|
||||
export const REST_CSRF_PROFILES = Object.freeze({
|
||||
NO_CSRF_BEARER: Object.freeze({
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
|
||||
@@ -121,6 +121,15 @@ function projectExecutionOutcome(
|
||||
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
|
||||
effect: outcome.effect,
|
||||
});
|
||||
case "AUTH_INTEGRATION_FAILURE":
|
||||
// §7.7. A configuration or collaborator breach, not a session state, so
|
||||
// it must not drive the re-authentication surface.
|
||||
return failure(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
operationId,
|
||||
outcome.reason,
|
||||
{ effect: outcome.effect },
|
||||
);
|
||||
case "TRANSPORT_FAILURE":
|
||||
return failure(
|
||||
outcome.failure.kind === "TIMEOUT"
|
||||
|
||||
Reference in New Issue
Block a user