diff --git a/src/bootstrap/create-runtime-composition.ts b/src/bootstrap/create-runtime-composition.ts index 8d1b712..516b586 100644 --- a/src/bootstrap/create-runtime-composition.ts +++ b/src/bootstrap/create-runtime-composition.ts @@ -1,4 +1,5 @@ import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts"; +import { installBffSessionOwner } from "../features/tech-log/adapters/http/bff-session-owner.ts"; import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; import { createCompositionRoot } from "./composition-root.ts"; import { loadReleaseManifest } from "./load-release-manifest.ts"; @@ -27,13 +28,31 @@ export async function createRuntimeComposition( loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }), loadRelease: (runtime) => loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }), - createAdapters: ({ config: runtime, release }) => - createRuntimeAdapters({ + createAdapters: ({ config: runtime, release }) => { + // `AUTH_MODE: "external"` delegates the session to whoever hosts this + // bundle. For Tech Log that host is its own backend — the session is an + // httpOnly cookie the SPA cannot read — so the owner is installed here, + // before the adapters resolve it. Only for the HTTP Studio: the MOCK + // source has no backend to ask, and `demo` keeps its own adapter. + const host = + dependencies.host ?? (globalThis as unknown as Record); + if ( + runtime.config.AUTH_MODE === "external" && + runtime.config.TECH_LOG_STUDIO_SOURCE === "HTTP" + ) { + installBffSessionOwner( + host, + runtime.config.API_BASE_URL, + dependencies.fetcher ?? fetch, + ); + } + return createRuntimeAdapters({ runtime, release, fetcher: dependencies.fetcher, host: dependencies.host, - }), + }); + }, }); const capabilities = resolveRuntimeCapabilities( diff --git a/src/features/tech-log/adapters/http/bff-session-owner.ts b/src/features/tech-log/adapters/http/bff-session-owner.ts new file mode 100644 index 0000000..22bcaf2 --- /dev/null +++ b/src/features/tech-log/adapters/http/bff-session-owner.ts @@ -0,0 +1,180 @@ +import type { SessionState } from "../../../../application/ports/auth-session-port.ts"; + +/** + * The host-installed session owner for the BFF deployment. + * + * `AUTH_MODE: "external"` means the page hosting this bundle owns the session + * and publishes it on `window.__CA_FRONTEND_AUTH_OWNER__`; with no owner + * present the runtime falls back to `createUnavailableSessionAdapter`, which is + * why a signed-in browser still saw "로그인 연동이 필요합니다". Tech Log's host + * *is* its backend: the session lives in an httpOnly `TECHLOG_SESSION` cookie + * the SPA cannot read, so the only way to observe it is to ask the backend. + * + * That is what this owner does. It is not a second authentication mechanism — + * `getStudioSession` is already the contract's bootstrap operation (the one + * that issues the CSRF token), so reading session state from it adds no + * round-trip the Studio would not make anyway. + */ + +const SESSION_PATH = "api/v1/studio/session"; +const SIGN_IN_PATH = "oauth2/authorization/keycloak"; +const SIGN_OUT_PATH = "logout"; + +type Listener = () => void; + +function endpoint(apiBaseUrl: string, path: string): string { + return new URL(path, apiBaseUrl).toString(); +} + +/** + * Starts at `recovery-pending` rather than `unauthenticated`. The state cannot + * be known synchronously and the router already models exactly this: a + * session-required route in that state renders the recovering surface and calls + * `recoverSession()`, which is where the probe belongs. Starting at + * `unauthenticated` would flash a sign-in prompt at a signed-in user on every + * reload. + */ +export function createBffSessionOwner( + apiBaseUrl: string, + fetcher: typeof fetch = fetch, +) { + let state: SessionState = "recovery-pending"; + // The session response carries the CSRF token; `/logout` is a mutation and the + // backend rejects it without one. Kept here so sign-out does not need a second + // round-trip on the happy path. + let csrf: Readonly<{ token: string; header: string }> | null = null; + const listeners = new Set(); + + const publish = (next: SessionState) => { + if (state === next) return; + state = next; + for (const listener of listeners) listener(); + }; + + async function probe(): Promise<"restored" | "no-session"> { + try { + const response = await fetcher(endpoint(apiBaseUrl, SESSION_PATH), { + method: "GET", + credentials: "include", + headers: { accept: "application/json" }, + }); + if (response.ok) { + csrf = await readCsrf(response); + publish("authenticated"); + return "restored"; + } + csrf = null; + // 401/403 are answers, not faults: the caller simply has no session. + if (response.status === 401 || response.status === 403) { + publish("unauthenticated"); + return "no-session"; + } + // 5xx means the backend could not say. Claiming "signed out" would send + // the user through a login they do not need, so report the integration + // as unavailable and let the shell surface that instead. + publish("integration-failed"); + return "no-session"; + } catch { + publish("integration-failed"); + return "no-session"; + } + } + + /** + * Total: a session that parses is still a session. A body we cannot read only + * costs sign-out its cached token, and `signOut` re-probes for one. + */ + async function readCsrf( + response: Response, + ): Promise | null> { + try { + const body = (await response.clone().json()) as { + data?: { csrfToken?: unknown; csrfHeaderName?: unknown }; + }; + const token = body?.data?.csrfToken; + const header = body?.data?.csrfHeaderName; + return typeof token === "string" && typeof header === "string" + ? Object.freeze({ token, header }) + : null; + } catch { + return null; + } + } + + // Probe immediately instead of waiting for the router's recovery button. The + // session is knowable without asking the user to do anything, and the button + // exists for owners that genuinely need a user gesture (a popup-based flow, + // say). Subscribers are notified when this settles, so a route that mounted + // during `recovery-pending` re-renders on its own. `recoverSession` remains + // wired for the manual path and for a retry after `integration-failed`. + void probe(); + + return Object.freeze({ + readState: () => state, + subscribe(listener: Listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + /** + * A full-page navigation, not a fetch: the authorization-code flow is a + * browser redirect chain through the identity provider, and an XHR cannot + * follow it. The backend sends the browser back to the SPA once the session + * cookie is set. + */ + async beginSignIn(): Promise { + globalThis.location.assign(endpoint(apiBaseUrl, SIGN_IN_PATH)); + }, + /** + * Only reports signed-out when the backend actually ended the session. + * + * The first version published `unauthenticated` in a `finally`, which read + * as defensive but was the opposite: `/logout` is a mutation and answered + * 403 without the CSRF header, so the cookie survived while the UI claimed + * the user was out — the exact failure someone on a shared machine would + * never think to check. A sign-out that did not happen has to look like a + * sign-out that did not happen. + */ + async signOut(): Promise { + if (csrf === null) { + // No cached token (never probed, or the probe body was unreadable). + // Ask again rather than sending a request that is certain to 403. + await probe(); + } + const headers: Record = csrf + ? { [csrf.header]: csrf.token } + : {}; + const response = await fetcher(endpoint(apiBaseUrl, SIGN_OUT_PATH), { + method: "POST", + credentials: "include", + headers, + }); + if (!response.ok) { + throw new Error( + `sign-out failed with status ${response.status}; the session is still active`, + ); + } + csrf = null; + publish("unauthenticated"); + }, + /** Cookies travel on their own; the CSRF header comes from its own collaborator. */ + async attachCredential() { + return Object.freeze({ headers: Object.freeze({}) }); + }, + recoverSession: probe, + notifyUnauthenticated() { + publish("unauthenticated"); + }, + }); +} + +/** + * Publishes the owner on the host global the runtime reads. Called before + * `createRuntimeAdapters`, which resolves the owner once and keeps it. + */ +export function installBffSessionOwner( + host: Record, + apiBaseUrl: string, + fetcher: typeof fetch = fetch, +): void { + host["__CA_FRONTEND_AUTH_OWNER__"] = createBffSessionOwner(apiBaseUrl, fetcher); +} diff --git a/src/features/tech-log/presentation/studio/components/studio-header.tsx b/src/features/tech-log/presentation/studio/components/studio-header.tsx index b99ef78..7d4e47e 100644 --- a/src/features/tech-log/presentation/studio/components/studio-header.tsx +++ b/src/features/tech-log/presentation/studio/components/studio-header.tsx @@ -1,5 +1,8 @@ import { useId, useState } from "react"; +import { useLocale } from "../../../../../presentation/i18n/index.ts"; +import { useSession } from "../../../../../presentation/providers/session-provider.tsx"; + import { techLogNavigation } from "../../tech-log-navigation.ts"; import { GuardedStudioLink } from "./guarded-studio-link.tsx"; @@ -40,10 +43,38 @@ function StudioNavigation({ ))} 공개 사이트 보기 + ); } +/** + * 로그아웃 자리. 세션 조작은 템플릿의 {@code AppShell} 에만 있었는데 TechLog 는 자체 셸을 쓰므로 + * 어느 화면에도 렌더되지 않았다 — 로그인은 되는데 로그아웃할 방법이 없었다. + * + * 인증된 상태에서만 그린다. 미인증이면 라우터의 인증 게이트 화면이 이미 로그인 조작을 들고 있어서 + * 여기 같은 버튼을 또 두면 두 개가 생긴다. + */ +function StudioSessionAction() { + const { sessionState, signOut } = useSession(); + const { message } = useLocale(); + const [pending, setPending] = useState(false); + if (sessionState !== "authenticated") return null; + return ( + + ); +} + export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) { const [open, setOpen] = useState(false); const mobileId = useId();