From 6a3bb19e7dd89454bebe04f08f399913fd1a59a7 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 6 Apr 2026 13:19:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=86=A0=EA=B8=80=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EC=A0=95=EC=B1=85=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.tsx | 51 +++++++++++++++++- adminfront/src/lib/auth.ts | 2 +- adminfront/src/lib/sessionSliding.test.ts | 53 +++++++++++++++++++ adminfront/src/lib/sessionSliding.ts | 31 +++++++++++ devfront/src/components/layout/AppLayout.tsx | 51 +++++++++++++++++- devfront/src/lib/auth.ts | 2 +- devfront/src/lib/sessionSliding.ts | 31 +++++++++++ 7 files changed, 217 insertions(+), 4 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 62a5e680..f82e6ea7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -19,7 +19,10 @@ import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { fetchMe } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding"; +import { + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; @@ -221,6 +224,52 @@ function AppLayout() { isSessionExpiryEnabled, ]); + useEffect(() => { + const maybeKeepSessionAlive = async () => { + const now = Date.now(); + if ( + !shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: auth.user?.expires_at, + nowMs: now, + isEnabled: isSessionExpiryEnabled, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + isRenewInFlight: isRenewInFlightRef.current, + lastAttemptAtMs: lastRenewAttemptAtRef.current, + }) + ) { + return; + } + + isRenewInFlightRef.current = true; + lastRenewAttemptAtRef.current = now; + + try { + await auth.signinSilent(); + } catch (error) { + console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + } finally { + isRenewInFlightRef.current = false; + } + }; + + const timer = window.setInterval(() => { + void maybeKeepSessionAlive(); + }, 30_000); + + void maybeKeepSessionAlive(); + + return () => { + window.clearInterval(timer); + }; + }, [ + auth, + auth.isAuthenticated, + auth.isLoading, + auth.user?.expires_at, + isSessionExpiryEnabled, + ]); + useEffect(() => { const routeKey = `${location.pathname}${location.search}${location.hash}`; if (lastVisitedRouteRef.current === null) { diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index 8f46d964..aab02a2b 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -10,7 +10,7 @@ export const oidcConfig: AuthProviderProps = { scope: "openid offline_access profile email", // offline_access for refresh token post_logout_redirect_uri: window.location.origin, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: true, + automaticSilentRenew: false, }; export const userManager = new UserManager({ diff --git a/adminfront/src/lib/sessionSliding.test.ts b/adminfront/src/lib/sessionSliding.test.ts index 410ac63e..cce36661 100644 --- a/adminfront/src/lib/sessionSliding.test.ts +++ b/adminfront/src/lib/sessionSliding.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SESSION_RENEW_THRESHOLD_MS, shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, } from "./sessionSliding"; describe("shouldAttemptSlidingSessionRenew", () => { @@ -71,3 +72,55 @@ describe("shouldAttemptSlidingSessionRenew", () => { ).toBe(false); }); }); + +describe("shouldAttemptUnlimitedSessionRenew", () => { + const nowMs = 1_700_000_000_000; + + it("returns false when unlimited mode is not active", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000, + ), + nowMs, + isEnabled: true, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(false); + }); + + it("returns true near expiry when session expiry management is disabled", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000, + ), + nowMs, + isEnabled: false, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(true); + }); + + it("returns false when the token still has enough remaining lifetime", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000, + ), + nowMs, + isEnabled: false, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(false); + }); +}); diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts index 7096e7f3..be152778 100644 --- a/adminfront/src/lib/sessionSliding.ts +++ b/adminfront/src/lib/sessionSliding.ts @@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({ return true; } + +export function shouldAttemptUnlimitedSessionRenew({ + expiresAtSec, + nowMs, + isEnabled, + isAuthenticated, + isLoading, + isRenewInFlight, + lastAttemptAtMs, + thresholdMs = SESSION_RENEW_THRESHOLD_MS, + throttleMs = SESSION_RENEW_THROTTLE_MS, +}: SlidingSessionRenewDecisionParams) { + if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { + return false; + } + + if (typeof expiresAtSec !== "number") { + return false; + } + + const remainingMs = expiresAtSec * 1000 - nowMs; + if (remainingMs <= 0 || remainingMs > thresholdMs) { + return false; + } + + if (nowMs - lastAttemptAtMs < throttleMs) { + return false; + } + + return true; +} diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 21c43d00..4e0eb33b 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -15,7 +15,10 @@ import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { fetchMe } from "../../features/auth/authApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; -import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding"; +import { + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; @@ -151,6 +154,52 @@ function AppLayout() { isSessionExpiryEnabled, ]); + useEffect(() => { + const maybeKeepSessionAlive = async () => { + const now = Date.now(); + if ( + !shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: auth.user?.expires_at, + nowMs: now, + isEnabled: isSessionExpiryEnabled, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + isRenewInFlight: isRenewInFlightRef.current, + lastAttemptAtMs: lastRenewAttemptAtRef.current, + }) + ) { + return; + } + + isRenewInFlightRef.current = true; + lastRenewAttemptAtRef.current = now; + + try { + await auth.signinSilent(); + } catch (error) { + console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + } finally { + isRenewInFlightRef.current = false; + } + }; + + const timer = window.setInterval(() => { + void maybeKeepSessionAlive(); + }, 30_000); + + void maybeKeepSessionAlive(); + + return () => { + window.clearInterval(timer); + }; + }, [ + auth, + auth.isAuthenticated, + auth.isLoading, + auth.user?.expires_at, + isSessionExpiryEnabled, + ]); + useEffect(() => { const routeKey = `${location.pathname}${location.search}${location.hash}`; if (lastVisitedRouteRef.current === null) { diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index f424d9d9..d0f0772e 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -11,7 +11,7 @@ export const oidcConfig: AuthProviderProps = { post_logout_redirect_uri: window.location.origin, popup_redirect_uri: `${window.location.origin}/auth/callback`, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: true, + automaticSilentRenew: false, }; export const userManager = new UserManager({ diff --git a/devfront/src/lib/sessionSliding.ts b/devfront/src/lib/sessionSliding.ts index 7096e7f3..be152778 100644 --- a/devfront/src/lib/sessionSliding.ts +++ b/devfront/src/lib/sessionSliding.ts @@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({ return true; } + +export function shouldAttemptUnlimitedSessionRenew({ + expiresAtSec, + nowMs, + isEnabled, + isAuthenticated, + isLoading, + isRenewInFlight, + lastAttemptAtMs, + thresholdMs = SESSION_RENEW_THRESHOLD_MS, + throttleMs = SESSION_RENEW_THROTTLE_MS, +}: SlidingSessionRenewDecisionParams) { + if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { + return false; + } + + if (typeof expiresAtSec !== "number") { + return false; + } + + const remainingMs = expiresAtSec * 1000 - nowMs; + if (remainingMs <= 0 || remainingMs > thresholdMs) { + return false; + } + + if (nowMs - lastAttemptAtMs < throttleMs) { + return false; + } + + return true; +}