forked from baron/baron-sso
세션 만료 관리 토글 동작을 실제 정책에 맞게 분리
This commit is contained in:
@@ -19,7 +19,10 @@ import { useAuth } from "react-oidc-context";
|
|||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
|
import {
|
||||||
|
shouldAttemptSlidingSessionRenew,
|
||||||
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
|
} from "../../lib/sessionSliding";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
@@ -221,6 +224,52 @@ function AppLayout() {
|
|||||||
isSessionExpiryEnabled,
|
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(() => {
|
useEffect(() => {
|
||||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||||
if (lastVisitedRouteRef.current === null) {
|
if (lastVisitedRouteRef.current === null) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const oidcConfig: AuthProviderProps = {
|
|||||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||||
post_logout_redirect_uri: window.location.origin,
|
post_logout_redirect_uri: window.location.origin,
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
automaticSilentRenew: true,
|
automaticSilentRenew: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userManager = new UserManager({
|
export const userManager = new UserManager({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
SESSION_RENEW_THRESHOLD_MS,
|
SESSION_RENEW_THRESHOLD_MS,
|
||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
} from "./sessionSliding";
|
} from "./sessionSliding";
|
||||||
|
|
||||||
describe("shouldAttemptSlidingSessionRenew", () => {
|
describe("shouldAttemptSlidingSessionRenew", () => {
|
||||||
@@ -71,3 +72,55 @@ describe("shouldAttemptSlidingSessionRenew", () => {
|
|||||||
).toBe(false);
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({
|
|||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { fetchMe } from "../../features/auth/authApi";
|
import { fetchMe } from "../../features/auth/authApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
|
import {
|
||||||
|
shouldAttemptSlidingSessionRenew,
|
||||||
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
|
} from "../../lib/sessionSliding";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
|
|
||||||
@@ -151,6 +154,52 @@ function AppLayout() {
|
|||||||
isSessionExpiryEnabled,
|
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(() => {
|
useEffect(() => {
|
||||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||||
if (lastVisitedRouteRef.current === null) {
|
if (lastVisitedRouteRef.current === null) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const oidcConfig: AuthProviderProps = {
|
|||||||
post_logout_redirect_uri: window.location.origin,
|
post_logout_redirect_uri: window.location.origin,
|
||||||
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
automaticSilentRenew: true,
|
automaticSilentRenew: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userManager = new UserManager({
|
export const userManager = new UserManager({
|
||||||
|
|||||||
@@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({
|
|||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user