import { InMemoryWebStorage, UserManager, WebStorageStateStore, } from "oidc-client-ts"; import { createPkcePair } from "./pkce.js"; const authority = "http://localhost:8080/realms/keycloak-patterns"; const backendBaseUrl = "http://localhost:8081"; const userManager = new UserManager({ authority, client_id: "spa-public", redirect_uri: "http://localhost:8088/callback.html", post_logout_redirect_uri: "http://localhost:8088/", response_type: "code", scope: "openid profile email", automaticSilentRenew: true, monitorSession: false, loadUserInfo: false, userStore: new WebStorageStateStore({ store: new InMemoryWebStorage(), }), stateStore: new WebStorageStateStore({ store: window.sessionStorage, }), }); const loginButton = document.querySelector("#login"); const logoutButton = document.querySelector("#logout"); const apiButton = document.querySelector("#call-api"); const pkceButton = document.querySelector("#pkce-demo"); const sessionState = document.querySelector("#session-state"); const result = document.querySelector("#result"); let currentUser = null; function renderJson(value) { result.textContent = JSON.stringify(value, null, 2); } function tokenMetadata(user) { return { subject: user.profile.sub, username: user.profile.preferred_username, expiresAt: new Date(user.expires_at * 1000).toISOString(), accessTokenHeldBy: "browser memory", refreshTokenHeldBy: user.refresh_token ? "browser memory" : "not issued", }; } function renderSession(user) { currentUser = user; const authenticated = Boolean(user && !user.expired); sessionState.dataset.authenticated = String(authenticated); sessionState.textContent = authenticated ? `${user.profile.preferred_username} 로그인됨 · token은 메모리에만 보관` : "로그인되지 않음 · 브라우저 저장소에 token 없음"; loginButton.disabled = authenticated; logoutButton.disabled = !authenticated; apiButton.disabled = !authenticated; if (authenticated) { renderJson(tokenMetadata(user)); } } async function finishSigninCallback() { const params = new URLSearchParams(window.location.search); const isCallback = window.location.pathname === "/callback.html" && (params.has("code") || params.has("error")); if (!isCallback) { return null; } const user = await userManager.signinRedirectCallback(); window.history.replaceState({}, document.title, "/"); return user; } async function callProtectedApi() { if (!currentUser || currentUser.expired) { throw new Error("로그인이 필요합니다."); } const response = await fetch(`${backendBaseUrl}/api/me`, { headers: { Authorization: `Bearer ${currentUser.access_token}`, }, }); const body = await response.json(); renderJson({ httpStatus: response.status, resourceServerResponse: body, tokenBoundary: tokenMetadata(currentUser), }); if (!response.ok) { throw new Error(`보호 API가 ${response.status}를 반환했습니다.`); } } loginButton.addEventListener("click", () => userManager.signinRedirect()); logoutButton.addEventListener("click", () => userManager.signoutRedirect()); apiButton.addEventListener("click", () => { callProtectedApi().catch((error) => renderJson({ error: error.message })); }); pkceButton.addEventListener("click", () => { createPkcePair() .then(({ verifier, challenge, method }) => renderJson({ method, verifierLength: verifier.length, challengeLength: challenge.length, note: "실제 로그인에서는 oidc-client-ts가 같은 S256 단계를 수행합니다.", })) .catch((error) => renderJson({ error: error.message })); }); userManager.events.addUserLoaded(renderSession); userManager.events.addUserUnloaded(() => renderSession(null)); userManager.events.addAccessTokenExpired(() => renderSession(null)); try { const callbackUser = await finishSigninCallback(); renderSession(callbackUser ?? await userManager.getUser()); } catch (error) { renderSession(null); renderJson({ error: error.message }); }