From 5ab36dde47b37cd13f41851727ef24278e64db1c Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Sat, 3 Oct 2026 13:46:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=EA=B3=B5=EC=9C=A0=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=BF=A0=ED=82=A4=EB=A5=BC=20=EC=83=81=EC=9C=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EA=B9=8C=EC=A7=80=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B3=A0=20=EA=B6=8C=ED=95=9C=20=EC=97=86=EC=9D=8C?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20=EC=A7=80=EC=9A=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드가 finditem.kr 계열 요청에는 토큰 쿠키를 .finditem.kr 도메인으로 발급하므로, 세션 만료로 토큰을 지울 때 Host 헤더를 보고 같은 도메인을 붙인다. 토큰을 운영 앱과 공유하게 되므로, 권한이 없는 계정은 토큰을 지우지 않고 로그인 화면에 안내만 띄워 운영 앱 세션이 끊기지 않게 한다. Co-Authored-By: Claude Opus 5.5 --- src/middleware.ts | 15 ++++++----- .../getAuthCookieDomain.test.ts | 21 ++++++++++++++++ .../getAuthCookieDomain.ts | 25 +++++++++++++++++++ src/utils/index.ts | 1 + tests/e2e/auth-login.spec.ts | 4 +++ 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts create mode 100644 src/utils/getAuthCookieDomain/getAuthCookieDomain.ts diff --git a/src/middleware.ts b/src/middleware.ts index 578b00a..bbee32a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,7 @@ import { NextResponse, NextRequest } from "next/server"; import { jwtDecode } from "jwt-decode"; import { ROLE_AREAS } from "@/constants/ROLE_AREAS"; +import { getAuthCookieDomain } from "@/utils/getAuthCookieDomain/getAuthCookieDomain"; interface JwtPayload { role?: string; @@ -23,9 +24,10 @@ const getHomeByRole = (role: string | null) => const findArea = (pathname: string) => ROLE_AREAS.find((area) => pathname === area.home || pathname.startsWith(`${area.home}/`)); -const clearTokens = (response: NextResponse) => { - response.cookies.set("access_token", "", { path: "/", maxAge: 0 }); - response.cookies.set("refresh_token", "", { path: "/", maxAge: 0 }); +const clearTokens = (request: NextRequest, response: NextResponse) => { + const domain = getAuthCookieDomain(request.headers.get("host")); + response.cookies.set("access_token", "", { path: "/", maxAge: 0, domain }); + response.cookies.set("refresh_token", "", { path: "/", maxAge: 0, domain }); return response; }; @@ -44,16 +46,17 @@ export function middleware(request: NextRequest) { return NextResponse.redirect(loginUrl); }; - // 권한이 없는 계정으로 로그인하면 토큰을 지우고 로그인 화면에 안내를 띄운다. + // 권한이 없는 계정이면 로그인 화면에 안내만 띄운다. + // 토큰은 운영 앱과 공유하므로 여기서 지우면 운영 앱 세션까지 끊긴다. const redirectForbidden = () => { const loginUrl = new URL(LOGIN_PATH, request.url); loginUrl.searchParams.set("reason", "forbidden"); - return clearTokens(NextResponse.redirect(loginUrl)); + return NextResponse.redirect(loginUrl); }; if (pathname.startsWith(LOGIN_PATH)) { const reason = request.nextUrl.searchParams.get("reason"); - if (reason === "session-expired") return clearTokens(NextResponse.next()); + if (reason === "session-expired") return clearTokens(request, NextResponse.next()); if (reason !== "forbidden" && accessToken && hasRefreshToken && home) return redirectTo(home); return NextResponse.next(); } diff --git a/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts b/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts new file mode 100644 index 0000000..5d0d333 --- /dev/null +++ b/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts @@ -0,0 +1,21 @@ +import { getAuthCookieDomain } from "./getAuthCookieDomain"; + +describe("getAuthCookieDomain", () => { + it.each(["finditem.kr", "www.finditem.kr", "a.finditem.kr", "a.finditem.kr:443"])( + "%s이면 상위 도메인을 반환한다", + (host) => { + expect(getAuthCookieDomain(host)).toBe(".finditem.kr"); + } + ); + + it.each(["localhost:3000", "evilfinditem.kr", "finditem.kr.evil.com", ""])( + "%s이면 도메인을 붙이지 않는다", + (host) => { + expect(getAuthCookieDomain(host)).toBeUndefined(); + } + ); + + it("Host 헤더가 없으면 도메인을 붙이지 않는다", () => { + expect(getAuthCookieDomain(null)).toBeUndefined(); + }); +}); diff --git a/src/utils/getAuthCookieDomain/getAuthCookieDomain.ts b/src/utils/getAuthCookieDomain/getAuthCookieDomain.ts new file mode 100644 index 0000000..4eb24da --- /dev/null +++ b/src/utils/getAuthCookieDomain/getAuthCookieDomain.ts @@ -0,0 +1,25 @@ +const AUTH_COOKIE_PARENT_DOMAIN = "finditem.kr"; + +/** + * 토큰 쿠키를 지울 때 붙일 `domain` 값을 접속 호스트로 판정합니다. + * + * @remarks + * - 백엔드가 finditem.kr 계열 요청에는 토큰 쿠키를 상위 도메인으로 발급하므로, 지울 때도 같은 도메인을 줘야 지워집니다. + * - dev 서버는 middleware의 `nextUrl`을 자기 주소로 만들기 때문에 `Host` 헤더를 넘겨 판정합니다. + * + * @param host - 요청의 `Host` 헤더 값 (포트 포함 가능) + * @returns finditem.kr 또는 그 하위 도메인이면 `".finditem.kr"`, 아니면 `undefined` + * + * @example + * ```ts + * getAuthCookieDomain("a.finditem.kr"); // ".finditem.kr" + * getAuthCookieDomain("localhost:3000"); // undefined + * ``` + */ +export const getAuthCookieDomain = (host: string | null) => { + const hostname = host?.split(":")[0] ?? ""; + return hostname === AUTH_COOKIE_PARENT_DOMAIN || + hostname.endsWith(`.${AUTH_COOKIE_PARENT_DOMAIN}`) + ? `.${AUTH_COOKIE_PARENT_DOMAIN}` + : undefined; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 8b5e27a..2b7f453 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,3 +14,4 @@ export { applyFiltersToUrl } from "./applyFiltersToUrl/applyFiltersToUrl"; export { getCategoryLabel } from "./getCategoryLabel/getCategoryLabel"; export { getServiceUrl } from "./getServiceUrl/getServiceUrl"; export { isValidCallbackUrl } from "./isValidCallbackUrl/isValidCallbackUrl"; +export { getAuthCookieDomain } from "./getAuthCookieDomain/getAuthCookieDomain"; diff --git a/tests/e2e/auth-login.spec.ts b/tests/e2e/auth-login.spec.ts index c6c8cd7..21573a9 100644 --- a/tests/e2e/auth-login.spec.ts +++ b/tests/e2e/auth-login.spec.ts @@ -49,6 +49,10 @@ test.describe("관리자 로그인 (/login)", () => { await expect(page).toHaveURL(/\/login\?reason=forbidden$/); await expect(page.getByText("관리자 권한이 있는 계정으로 로그인해 주세요.")).toBeVisible(); + + // 토큰은 운영 앱과 공유하므로 권한이 없어도 지우지 않는다. + const cookieNames = (await context.cookies()).map((cookie) => cookie.name); + expect(cookieNames).toEqual(expect.arrayContaining(["access_token", "refresh_token"])); }); test("관리자 계정으로 로그인 화면에 접근하면 관리자 메인으로 이동한다", async ({ From 1912b286259e961ad477006d2e9e981634d851f2 Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Mon, 5 Oct 2026 17:22:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(auth):=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20PR=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기능 PR과 테스트 PR을 나누는 규칙에 따라 단위 테스트와 e2e 수정을 이 브랜치에서 빼고 test/auth-cookie-domain 브랜치로 옮긴다. Co-Authored-By: Claude Opus 5.5 --- .../getAuthCookieDomain.test.ts | 21 ------------------- tests/e2e/auth-login.spec.ts | 4 ---- 2 files changed, 25 deletions(-) delete mode 100644 src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts diff --git a/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts b/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts deleted file mode 100644 index 5d0d333..0000000 --- a/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getAuthCookieDomain } from "./getAuthCookieDomain"; - -describe("getAuthCookieDomain", () => { - it.each(["finditem.kr", "www.finditem.kr", "a.finditem.kr", "a.finditem.kr:443"])( - "%s이면 상위 도메인을 반환한다", - (host) => { - expect(getAuthCookieDomain(host)).toBe(".finditem.kr"); - } - ); - - it.each(["localhost:3000", "evilfinditem.kr", "finditem.kr.evil.com", ""])( - "%s이면 도메인을 붙이지 않는다", - (host) => { - expect(getAuthCookieDomain(host)).toBeUndefined(); - } - ); - - it("Host 헤더가 없으면 도메인을 붙이지 않는다", () => { - expect(getAuthCookieDomain(null)).toBeUndefined(); - }); -}); diff --git a/tests/e2e/auth-login.spec.ts b/tests/e2e/auth-login.spec.ts index 21573a9..c6c8cd7 100644 --- a/tests/e2e/auth-login.spec.ts +++ b/tests/e2e/auth-login.spec.ts @@ -49,10 +49,6 @@ test.describe("관리자 로그인 (/login)", () => { await expect(page).toHaveURL(/\/login\?reason=forbidden$/); await expect(page.getByText("관리자 권한이 있는 계정으로 로그인해 주세요.")).toBeVisible(); - - // 토큰은 운영 앱과 공유하므로 권한이 없어도 지우지 않는다. - const cookieNames = (await context.cookies()).map((cookie) => cookie.name); - expect(cookieNames).toEqual(expect.arrayContaining(["access_token", "refresh_token"])); }); test("관리자 계정으로 로그인 화면에 접근하면 관리자 메인으로 이동한다", async ({