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; signOut(): Promise; attachCredential( binding: CredentialRequestBinding, context?: CredentialOperationContext, ): Promise; recoverSession(): Promise<"restored" | "no-session">; notifyUnauthenticated(): void; }>; const ALLOWED_CREDENTIAL_HEADERS = new Set(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).headers; if (!headers || typeof headers !== "object" || Array.isArray(headers)) { throw new TypeError("Auth owner returned an invalid credential patch"); } const projected: Record = {}; 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: () => {}, }); }