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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user