Files
frontend/apps/wr/app/(with-layout)/jwttest/page.tsx.bak
T

221 lines
6.0 KiB
Plaintext

/**
* JWT를 decoding하면 만료시간을 읽을 수 있다.
* JWT는 3부분으로 구성 : Header.Payload.Signature
* Payload 부분에 만료시간(exp)이 있다.
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { authService } from "@/src/entities/auth";
import { CLAIM_NAME } from "@/src/shared/config";
import { getCookie } from "@/src/shared/lib";
interface JwtPayload {
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
}
interface JwtState {
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
exp: number | null; // payload에서 추출한 exp 값
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
}
const initialJwtState: JwtState = {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: null,
};
function parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const jsonPayload = decodeURIComponent(
atob(padded)
.split("")
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("")
);
return JSON.parse(jsonPayload) as JwtPayload;
}
function formatRemainingMs(remainingMs: number | null): string {
if (remainingMs === null) return "-";
if (remainingMs <= 0) return "만료됨";
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}시간 ${minutes}분 ${seconds}초`;
}
function readJwtState(): JwtState {
const token = getCookie(CLAIM_NAME) ?? "";
if (!token) {
return {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
};
}
try {
const payload = parseJwt(token);
if (typeof payload.exp !== "number") {
return {
token,
payload,
exp: null,
expDateLocal: null,
remainingMs: null,
error: "JWT payload에 exp 값이 없습니다.",
};
}
const expMs = payload.exp * 1000;
return {
token,
payload,
exp: payload.exp,
expDateLocal: new Date(expMs).toLocaleString(),
remainingMs: Math.max(expMs - Date.now(), 0),
error: null,
};
} catch (error) {
return {
token,
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error:
error instanceof Error
? error.message
: "JWT 파싱 중 오류가 발생했습니다.",
};
}
}
export default function Page() {
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
const isRefreshingRef = useRef(false);
const handleSilentRefresh = async () => {
try {
await authService.silentRefresh();
} catch (error) {
console.error("Manual silent refresh failed:", error);
} finally {
setJwtState(readJwtState());
}
};
useEffect(() => {
const syncJwtState = async () => {
const nextState = readJwtState();
if (
nextState.remainingMs !== null &&
nextState.remainingMs > 0 &&
nextState.remainingMs < 5 * 60 * 1000 &&
!isRefreshingRef.current
) {
isRefreshingRef.current = true;
try {
await authService.silentRefresh();
} catch (error) {
console.error("JWT test silent refresh failed:", error);
}
}
setJwtState(readJwtState());
if (
nextState.remainingMs === null ||
nextState.remainingMs >= 5 * 60 * 1000 ||
nextState.remainingMs <= 0
) {
isRefreshingRef.current = false;
}
};
void syncJwtState();
const timer = window.setInterval(() => {
void syncJwtState();
}, 1000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="space-y-4 p-8 text-base">
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
onClick={() => {
void handleSilentRefresh();
}}
>
Silent Refresh 호출
</button>
<div className="rounded border p-4">
<div className="font-semibold">쿠키명</div>
<div>{CLAIM_NAME}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">exp</div>
<div>{jwtState.exp ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">만료시각</div>
<div>{jwtState.expDateLocal ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">남은시간</div>
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">상태</div>
<div>{jwtState.error ?? "정상"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">payload</div>
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
</pre>
</div>
</div>
);
}