FeaturesAuthToken Refresh

Token Refresh

SPEC #003 §2-4 + SPEC #006 §2-5 정합. helper apps/admin/src/lib/refresh.ts.

Overview

access (15min) 만료 임박 또는 401 시 BFF 가 refresh (7d, 임퍼소네이션은 60min) 사용해 새 토큰 rotation. 사용자에게 투명.

Trigger 시점

시점트리거책임
만료 임박 (≤30s)BFF helper refreshIfNeeded(session)proactive
backend 401BFF helper forceRefresh(session) 1회 재시도reactive
/auth/me 호출 시server component 도 동일RSC 정합

helper API (apps/admin/src/lib/refresh.ts)

// 의사
// 공통 내부: refresh 토큰 교환 + 세션 재봉인. 실패는 swallow → boolean.
// 세션 파기(destroy)는 helper 가 하지 않고 호출자(BFF route)가 결정한다.
const applyAuth = async (session: Session, refreshToken: string): Promise<boolean> => {
  try {
    const auth = await backendRefresh(refreshToken);  // 5xx·네트워크 → throw
    session.accessToken = auth.accessToken;
    session.refreshToken = auth.refreshToken;
    // accessExpiresInSeconds(수명, 초) → 절대 시각 / refreshExpiresAt(ISO 절대시각) → epoch ms
    session.accessExpiresAt = computeAccessExpiresAt(auth.accessExpiresInSeconds);
    session.refreshExpiresAt = new Date(auth.refreshExpiresAt).getTime();
    session.accountId = auth.accountId;
    session.role = auth.role;
    await session.save();
    return true;
  } catch {
    return false;  // swallow — destroy 안 함
  }
};
 
// proactive: 만료 임박일 때만 교환. 불필요/refresh 토큰 없음 → 세션 그대로 반환 (throw X).
export const refreshIfNeeded = async (session: Session): Promise<Session> => {
  if (!accessIsLikelyExpired(session)) return session;
  if (!session.refreshToken) return session;
  await applyAuth(session, session.refreshToken);
  return session;
};
 
// reactive: backend 401 후 강제. 성공 여부만 boolean 으로 반환 (throw·destroy X).
export const forceRefresh = async (session: Session): Promise<boolean> => {
  if (!session.refreshToken) return false;
  return applyAuth(session, session.refreshToken);
};

Rotation 정책 (backend)

  • /refresh 호출 시 prev refresh revokedAt = now().
  • 새 refresh 발급 + DB INSERT.
  • 같은 client 가 race condition 으로 두 번 refresh 호출 → 두 번째는 revoked → 401.
    • BFF 가 1회 재시도 후 또 401 이면 session.destroy().

Constraints ([[feedback-1]] · [[feedback-3]])

  • 5xx 에서 session 유지 — refresh 자체가 5xx 면 BACKEND_ERROR. 다음 호출에서 다시 시도.
  • 401 만 session.destroy() — 진짜 refresh 만료 / revoked.
  • Server Component 도 refresh-aware(protected)/layout.tsx 에서 backend 호출 시 동일 패턴.
  • /api/* 호출은 middleware 가 redirect X — BFF 가 JSON 401 응답.

RSC 패턴

// apps/admin/src/app/(protected)/layout.tsx
const ProtectedLayout = async ({ children }) => {
  const session = await getSession();
  if (!session.accessToken) redirect("/login");
 
  await refreshIfNeeded(session);              // proactive (세션 반환, throw X)
  if (!session.accessToken) redirect("/login");
 
  const fetchMe = () => backendMe(session.accessToken!);  // 시그니처: (accessToken: string)
  let me = null;
  try {
    me = await fetchMe();
  } catch (err) {
    if (err instanceof BackendCallError && err.status === 401) {
      // 늦게 잡힌 만료 — reactive refresh 1회 후 재호출
      if (await forceRefresh(session) && session.accessToken) {
        try { me = await fetchMe(); }
        catch { session.destroy(); redirect("/login"); }
      } else {
        session.destroy(); redirect("/login");  // 파기는 호출자 책임
      }
    }
    // 5xx·네트워크(비-401): shell 유지, 페이지 단에서 재시도
  }
  if (me?.passwordMustChange) redirect("/onboarding/change-password");
  // 운영사 셸은 단일 컴포넌트가 아니라 Topbar + Sidebar 조합 (apps/admin)
  return (
    <ToastHostProvider>
      <div data-surface="ops">
        <Topbar email={me?.email ?? null} name={me?.name ?? null} />
        <div>
          <Sidebar />
          <main>{children}</main>
        </div>
      </div>
    </ToastHostProvider>
  );
};

States & Edge Cases

상태처리
access 유효 (≥30s)refresh skip
access 만료 임박proactive refresh
access 만료 + refresh 유효refresh OK
access 만료 + refresh 만료 (401)session.destroy() + redirect /login
refresh race condition (revoked)1회 재시도 후 fail → destroy
backend 5xxsession 유지 + 502 BACKEND_ERROR

Roadmap

  • sliding 갱신 활성화 (사용자 활동 기준 refresh TTL 연장) — 현재 backend 정책 의존
  • all-device logout (현재는 token 단위만)

References

  • SPEC #003 §2-4
  • SPEC #006 §2-5-3
  • linkmusic-frontend-space/apps/admin/src/lib/refresh.ts
  • linkmusic-frontend-space/apps/admin/src/lib/backend.ts