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>
153 lines
5.0 KiB
TypeScript
153 lines
5.0 KiB
TypeScript
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,
|
|
context?: CredentialOperationContext,
|
|
): Promise<CredentialPatch>;
|
|
recoverSession(): Promise<"restored" | "no-session">;
|
|
notifyUnauthenticated(): void;
|
|
}>;
|
|
|
|
const ALLOWED_CREDENTIAL_HEADERS = new Set<string>(CREDENTIAL_HEADER_NAMES);
|
|
const MAX_HEADER_VALUE_BYTES = 8_192;
|
|
|
|
export function validateCredentialPatch(value: unknown): CredentialPatch {
|
|
if (!value || typeof value !== "object") {
|
|
throw new TypeError("Auth owner returned an invalid credential patch");
|
|
}
|
|
const headers = (value as Record<string, unknown>).headers;
|
|
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
|
throw new TypeError("Auth owner returned an invalid credential patch");
|
|
}
|
|
const projected: Record<string, string> = {};
|
|
for (const [name, headerValue] of Object.entries(headers)) {
|
|
const normalizedName = name.toLowerCase();
|
|
if (
|
|
!ALLOWED_CREDENTIAL_HEADERS.has(normalizedName) ||
|
|
typeof headerValue !== "string" ||
|
|
headerValue.length === 0 ||
|
|
new TextEncoder().encode(headerValue).byteLength > MAX_HEADER_VALUE_BYTES ||
|
|
/[\r\n]/.test(headerValue)
|
|
) {
|
|
throw new TypeError("Auth owner returned a forbidden credential patch");
|
|
}
|
|
projected[normalizedName] = headerValue;
|
|
}
|
|
return Object.freeze({ headers: Object.freeze(projected) });
|
|
}
|
|
|
|
export function createExternalAuthSessionAdapter(
|
|
owner: ExternalSessionOwner,
|
|
): AuthSessionPort {
|
|
return Object.freeze({
|
|
getState: () => owner.readState(),
|
|
subscribe: (listener) => owner.subscribe(listener),
|
|
beginSignIn: (returnTo) => owner.beginSignIn(returnTo),
|
|
signOut: () => owner.signOut(),
|
|
async credentialPatch(binding, context) {
|
|
return validateCredentialPatch(
|
|
await owner.attachCredential(binding, context),
|
|
);
|
|
},
|
|
async recover() {
|
|
const result = await owner.recoverSession();
|
|
if (result !== "restored" && result !== "no-session") {
|
|
throw new TypeError("Auth owner returned an invalid recovery state");
|
|
}
|
|
return result;
|
|
},
|
|
onUnauthenticated: () => owner.notifyUnauthenticated(),
|
|
});
|
|
}
|
|
|
|
const EMPTY_PATCH = Object.freeze({ headers: Object.freeze({}) });
|
|
|
|
export function createAnonymousSessionAdapter(): AuthSessionPort {
|
|
return createExternalAuthSessionAdapter({
|
|
readState: () => "unauthenticated",
|
|
subscribe: () => () => {},
|
|
beginSignIn: async () => {},
|
|
signOut: async () => {},
|
|
attachCredential: async () => EMPTY_PATCH,
|
|
recoverSession: async () => "no-session",
|
|
notifyUnauthenticated: () => {},
|
|
});
|
|
}
|
|
|
|
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) => {
|
|
state = next;
|
|
for (const listener of listeners) listener();
|
|
};
|
|
return Object.freeze({
|
|
getState: () => state,
|
|
subscribe(listener) {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
async beginSignIn() {
|
|
setState("authenticated");
|
|
},
|
|
async signOut() {
|
|
setState("unauthenticated");
|
|
},
|
|
credentialPatch: async () => patch,
|
|
async recover() {
|
|
if (state === "recovery-pending") {
|
|
setState("authenticated");
|
|
return "restored";
|
|
}
|
|
return "no-session";
|
|
},
|
|
onUnauthenticated: () => setState("unauthenticated"),
|
|
setState,
|
|
});
|
|
}
|
|
|
|
export function createUnavailableSessionAdapter(): AuthSessionPort {
|
|
return Object.freeze({
|
|
getState: () => "integration-failed",
|
|
subscribe: () => () => {},
|
|
beginSignIn: async () => {},
|
|
signOut: async () => {},
|
|
credentialPatch: async () => {
|
|
throw new TypeError("External session integration is unavailable");
|
|
},
|
|
recover: async () => "no-session" as const,
|
|
onUnauthenticated: () => {},
|
|
});
|
|
}
|