Files
clean-architecture-frontend…/src/adapters/auth/external-session-adapter.ts
T
DongHyeonkaandClaude Opus 5 4e87bacdf3 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>
2026-08-13 22:56:44 +09:00

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: () => {},
});
}