From f1498feee5a4d7ac57edeaa74199055a57856997 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 02:21:16 +0900 Subject: [PATCH] feat: let the SPA see the backend session, and give it a way out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps that only showed up once the backend's BFF login worked. The SPA could not tell it was signed in. `AUTH_MODE: "external"` delegates the session to whoever hosts the bundle, via `window.__CA_FRONTEND_AUTH_OWNER__`; nothing installed one, so the runtime fell back to `createUnavailableSessionAdapter` and a browser holding a valid TECHLOG_SESSION cookie still saw "로그인 연동이 필요합니다". Tech Log's host is its own backend. The session is an httpOnly cookie the SPA cannot read, so the only way to observe it is to ask — which is what `getStudioSession` already is, the contract's bootstrap operation that issues the CSRF token. The owner probes it on creation and publishes the result: 200 authenticated, 401/403 unauthenticated, anything else integration-failed (claiming "signed out" on a 5xx would push the user through a login they do not need). It starts at `recovery-pending` so a signed-in user does not get a sign-in flash on every reload, and signs in by navigating the browser to the authorization endpoint — the code flow is a redirect chain an XHR cannot follow. Installed from create-runtime-composition, before the adapters resolve it, and only for AUTH_MODE=external with the HTTP Studio: MOCK has no backend to ask and demo keeps its own adapter. There was also no way to sign out. `signOut` is wired all the way to the port and the label exists in both catalogs, but the button lives in the template's AppShell, which TechLog never renders — it supplies its own public and studio shells. The Studio header now carries it, drawn only when authenticated so it does not duplicate the sign-in the auth gate already offers. Sign-out sends the CSRF header it caches from the session probe, and reports failure instead of swallowing it. The first attempt did neither: `/logout` is a mutation, answered 403 without the header, and the owner published `unauthenticated` from a `finally` — so the cookie survived while the UI claimed the user was out. That is the one failure someone on a shared machine would never think to check, so a sign-out that did not happen now throws and leaves the state alone. Verified against the running backend with a production-profile build: /studio signed out 401 probe → sign-in surface → Keycloak after login session 200, dashboard 200, real data rendered sign out 204, TECHLOG_SESSION cleared, /studio/documents back to the sign-in surface check:types, lint, check:architecture, check:tech-log-contract, check:dev-release-manifest and check:browser-security all pass. test:all is 1817 passed with two load-dependent flakes that pass in isolation (provider-guardian-transaction, security-followup) and reference none of the changed files — security-followup kills process groups, which is also what was killing the Gradle daemon when both suites ran at once. Co-Authored-By: Claude Opus 5 (1M context) --- src/bootstrap/create-runtime-composition.ts | 25 ++- .../adapters/http/bff-session-owner.ts | 180 ++++++++++++++++++ .../studio/components/studio-header.tsx | 31 +++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 src/features/tech-log/adapters/http/bff-session-owner.ts 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();