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
+138 -1
View File
@@ -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",