feat: let the SPA see the backend session, and give it a way out
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5fe355483e
commit
f1498feee5
@@ -1,4 +1,5 @@
|
|||||||
import { resolveRuntimeCapabilities } from "../contracts/runtime-capabilities.ts";
|
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 { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts";
|
||||||
import { createCompositionRoot } from "./composition-root.ts";
|
import { createCompositionRoot } from "./composition-root.ts";
|
||||||
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
import { loadReleaseManifest } from "./load-release-manifest.ts";
|
||||||
@@ -27,13 +28,31 @@ export async function createRuntimeComposition(
|
|||||||
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }),
|
||||||
loadRelease: (runtime) =>
|
loadRelease: (runtime) =>
|
||||||
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
loadReleaseManifest(runtime, { fetcher: dependencies.fetcher }),
|
||||||
createAdapters: ({ config: runtime, release }) =>
|
createAdapters: ({ config: runtime, release }) => {
|
||||||
createRuntimeAdapters({
|
// `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<string, unknown>);
|
||||||
|
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,
|
runtime,
|
||||||
release,
|
release,
|
||||||
fetcher: dependencies.fetcher,
|
fetcher: dependencies.fetcher,
|
||||||
host: dependencies.host,
|
host: dependencies.host,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const capabilities = resolveRuntimeCapabilities(
|
const capabilities = resolveRuntimeCapabilities(
|
||||||
|
|||||||
@@ -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<Listener>();
|
||||||
|
|
||||||
|
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<Readonly<{ token: string; header: string }> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string> = 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<string, unknown>,
|
||||||
|
apiBaseUrl: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): void {
|
||||||
|
host["__CA_FRONTEND_AUTH_OWNER__"] = createBffSessionOwner(apiBaseUrl, fetcher);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useId, useState } from "react";
|
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 { techLogNavigation } from "../../tech-log-navigation.ts";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
|
||||||
@@ -40,10 +43,38 @@ function StudioNavigation({
|
|||||||
</GuardedStudioLink>
|
</GuardedStudioLink>
|
||||||
))}
|
))}
|
||||||
<a href="/">공개 사이트 보기</a>
|
<a href="/">공개 사이트 보기</a>
|
||||||
|
<StudioSessionAction />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃 자리. 세션 조작은 템플릿의 {@code AppShell} 에만 있었는데 TechLog 는 자체 셸을 쓰므로
|
||||||
|
* 어느 화면에도 렌더되지 않았다 — 로그인은 되는데 로그아웃할 방법이 없었다.
|
||||||
|
*
|
||||||
|
* 인증된 상태에서만 그린다. 미인증이면 라우터의 인증 게이트 화면이 이미 로그인 조작을 들고 있어서
|
||||||
|
* 여기 같은 버튼을 또 두면 두 개가 생긴다.
|
||||||
|
*/
|
||||||
|
function StudioSessionAction() {
|
||||||
|
const { sessionState, signOut } = useSession();
|
||||||
|
const { message } = useLocale();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
if (sessionState !== "authenticated") return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-session-action"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
setPending(true);
|
||||||
|
void signOut().finally(() => setPending(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message("action.signOut")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
|
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const mobileId = useId();
|
const mobileId = useId();
|
||||||
|
|||||||
Reference in New Issue
Block a user